From d01a2917b1a767d3b1eec5dea2df9e8001af29e5 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Thu, 11 Jun 2026 19:07:43 -0600 Subject: [PATCH] feat: add Jewel Quest game and fix Portrait memory leaks - Register new Jewel Quest game across client and server - Update game icons spritesheet with new frame (59) - Fix memory leaks in Portrait components by implementing proper cleanup with double-destruction guards and returning destroy function --- public/assets/images/game-icons.png | Bin 221982 -> 225878 bytes public/assets/images/game-icons.psd | Bin 594190 -> 602789 bytes public/data/jewelquest.json | 52 + public/src/games/jewelquest/JewelQuestAI.js | 163 +++ public/src/games/jewelquest/JewelQuestData.js | 164 +++ public/src/games/jewelquest/JewelQuestGame.js | 1188 +++++++++++++++++ .../src/games/jewelquest/JewelQuestLogic.js | 675 ++++++++++ public/src/games/jewelquest/tutorial.md | 50 + public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- public/src/scenes/PreloadScene.js | 1 + public/src/ui/Portrait.js | 21 +- server/games/registry.js | 1 + server/scripts/verifyJewelQuest.js | 603 +++++++++ 14 files changed, 2918 insertions(+), 4 deletions(-) create mode 100644 public/data/jewelquest.json create mode 100644 public/src/games/jewelquest/JewelQuestAI.js create mode 100644 public/src/games/jewelquest/JewelQuestData.js create mode 100644 public/src/games/jewelquest/JewelQuestGame.js create mode 100644 public/src/games/jewelquest/JewelQuestLogic.js create mode 100644 public/src/games/jewelquest/tutorial.md create mode 100644 server/scripts/verifyJewelQuest.js diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 2de7ec3d58a9623292c123f368817a7bc01918de..093b0f2228d15cf2e1f4c17bd5e969f80755a196 100644 GIT binary patch delta 65690 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 ADgXcg delta 61763 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 aMD{1&_?)ZFq58s~M?qHgZSkA;0sjF^ZI##n diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index d79acfe18a5c953972cd38aaf26d8a702a4b4990..dfa7a476375f4301885888f0d279838ac29e5d91 100644 GIT binary patch delta 13890 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 S(}(=Eh5Q8Lu(E|*D)B$T)Yv5e diff --git a/public/data/jewelquest.json b/public/data/jewelquest.json new file mode 100644 index 0000000..1001a5d --- /dev/null +++ b/public/data/jewelquest.json @@ -0,0 +1,52 @@ +{ + "playerBaseHp": 50, + "milestones": [ + { "afterLevel": 5, "unlockSpellSlot": 4, "maxHpBonus": 5 }, + { "afterLevel": 10, "unlockSpellSlot": 5, "maxHpBonus": 5 }, + { "afterLevel": 15, "maxHpBonus": 10 } + ], + "levels": [ + { "level": 1, "opponentId": "ethel", "class": "druid", "skill": 1, "hp": 30, "spellCount": 2, + "tagline": "A gentle gem warm-up over tea." }, + { "level": 2, "opponentId": "kona", "class": "knight", "skill": 1, "hp": 32, "spellCount": 2, + "tagline": "Good puppy. Surprisingly sharp teeth on those skulls." }, + { "level": 3, "opponentId": "bernie", "class": "knight", "skill": 2, "hp": 34, "spellCount": 3, + "tagline": "All fun and games... until the skulls line up." }, + { "level": 4, "opponentId": "brad", "class": "assassin", "skill": 2, "hp": 36, "spellCount": 3, + "tagline": "He'll steal your salmon AND your mana." }, + { "level": 5, "opponentId": "jerry", "class": "knight", "skill": 3, "hp": 38, "spellCount": 3, + "tagline": "Y'all ready for a real scrap?" }, + { "level": 6, "opponentId": "jeff", "class": "sorcerer", "skill": 3, "hp": 40, "spellCount": 3, + "tagline": "Casts slow. Wins fast. You've been warned." }, + { "level": 7, "opponentId": "mario", "class": "knight", "skill": 4, "hp": 42, "spellCount": 4, + "tagline": "Welcome to the maze of matching gems!" }, + { "level": 8, "opponentId": "juliet", "class": "druid", "skill": 4, "hp": 44, "spellCount": 4, + "tagline": "A summer day, a storm of mana." }, + { "level": 9, "opponentId": "michael", "class": "druid", "skill": 5, "hp": 46, "spellCount": 4, + "tagline": "Easy vibes, heavy cascades, mon." }, + { "level": 10, "opponentId": "croc", "class": "assassin", "skill": 5, "hp": 48, "spellCount": 4, + "tagline": "The party's over when your mana runs dry." }, + { "level": 11, "opponentId": "gerome", "class": "knight", "skill": 6, "hp": 51, "spellCount": 4, + "tagline": "Extreme victory or nothing!" }, + { "level": 12, "opponentId": "beth", "class": "assassin", "skill": 6, "hp": 54, "spellCount": 4, + "tagline": "These parts have rules, stranger. Rule one: my turn." }, + { "level": 13, "opponentId": "steve", "class": "sorcerer", "skill": 7, "hp": 57, "spellCount": 5, + "tagline": "Stupid Earth gems. Prepare to lose.", + "weights": { "wild": 4 } }, + { "level": 14, "opponentId": "fireball", "class": "sorcerer", "skill": 7, "hp": 60, "spellCount": 5, + "tagline": "No x-ray eyes. Just flawless fireballs." }, + { "level": 15, "opponentId": "natasha", "class": "assassin", "skill": 8, "hp": 63, "spellCount": 5, + "tagline": "Your secrets vanish with your mana." }, + { "level": 16, "opponentId": "victor", "class": "sorcerer", "skill": 8, "hp": 66, "spellCount": 5, + "tagline": "Every cascade calculated. Centuries ago." }, + { "level": 17, "opponentId": "balam", "class": "druid", "skill": 9, "hp": 69, "spellCount": 5, + "tagline": "Mystical powers meet matching gems." }, + { "level": 18, "opponentId": "cybro", "class": "sorcerer", "skill": 9, "hp": 72, "spellCount": 5, + "tagline": "The future has already countered you." }, + { "level": 19, "opponentId": "zanthor", "class": "sorcerer", "skill": 10, "hp": 74, "spellCount": 5, + "tagline": "Alacazam! Your hit points are doomed!" }, + { "level": 20, "opponentId": "blackwind", "class": "assassin", "skill": 10, "hp": 75, "spellCount": 5, + "tagline": "The final showdown on the high seas!", + "weights": { "skull": 14, "skull5": 3 } } + ] +} diff --git a/public/src/games/jewelquest/JewelQuestAI.js b/public/src/games/jewelquest/JewelQuestAI.js new file mode 100644 index 0000000..e6f5778 --- /dev/null +++ b/public/src/games/jewelquest/JewelQuestAI.js @@ -0,0 +1,163 @@ +// Jewel Quest AI — turn-based action search over the headless engine. +// Skill 1-10 controls blunder rate, how many refill samples are averaged per +// candidate swap, spell intelligence, opponent awareness, and how long the AI +// "thinks" before acting (scene pacing only). +// +// Unlike Block Fighter's input-throttled AI, this one is called once per turn: +// chooseAction() returns either { type:'swap', a, b } or +// { type:'spell', spellId }. + +import { + SIZE, MANA_COLORS, legalSwaps, previewSwapRuns, simulateSwap, simulateSpell, + canAfford, makeRng, SKULL5_BONUS, +} from './JewelQuestLogic.js'; +import { SPELLS } from './JewelQuestData.js'; + +// spellIQ: 0=never casts, 1=eager (casts almost anything), 2=basic (compares +// spell vs best swap), 3=smart (basic + opponent threat awareness). +const SKILL_ANCHORS = [ + { skill: 1, blunder: 0.45, samples: 1, spellIQ: 0, oppAware: false, thinkMs: 950 }, + { skill: 3, blunder: 0.25, samples: 1, spellIQ: 1, oppAware: false, thinkMs: 800 }, + { skill: 5, blunder: 0.10, samples: 1, spellIQ: 2, oppAware: false, thinkMs: 650 }, + { skill: 7, blunder: 0.05, samples: 2, spellIQ: 3, oppAware: true, thinkMs: 500 }, + { skill: 10, blunder: 0.00, samples: 3, spellIQ: 3, oppAware: true, thinkMs: 350 }, +]; + +function knobsFor(skill) { + const s = Math.max(1, Math.min(10, skill)); + let lo = SKILL_ANCHORS[0], hi = SKILL_ANCHORS[SKILL_ANCHORS.length - 1]; + for (let i = 0; i < SKILL_ANCHORS.length - 1; i++) { + if (s >= SKILL_ANCHORS[i].skill && s <= SKILL_ANCHORS[i + 1].skill) { + lo = SKILL_ANCHORS[i]; hi = SKILL_ANCHORS[i + 1]; + break; + } + } + const t = hi.skill === lo.skill ? 0 : (s - lo.skill) / (hi.skill - lo.skill); + return { + blunder: lo.blunder + (hi.blunder - lo.blunder) * t, + samples: t < 0.5 ? lo.samples : hi.samples, + spellIQ: t < 0.5 ? lo.spellIQ : hi.spellIQ, + oppAware: t < 0.5 ? lo.oppAware : hi.oppAware, + thinkMs: Math.round(lo.thinkMs + (hi.thinkMs - lo.thinkMs) * t), + }; +} + +export function createAI({ skill = 5, seed = 1 } = {}) { + return { skill, knobs: knobsFor(skill), rng: makeRng(seed) }; +} + +const W = { + damage: 12, + heal: 9, + keptTurn: 14, + manaNeeded: 2.0, + manaSpare: 0.7, + drain: 1.2, + steal: 1.8, + buffPerPoint: 5, + stun: 16, + threat: 7, + win: 1e6, +}; + +// Mana for colors we still need toward an unlocked spell is worth more. +function manaWeights(player) { + const weights = {}; + for (const color of MANA_COLORS) weights[color] = W.manaSpare; + for (const id of player.spells) { + for (const [color, amt] of Object.entries(SPELLS[id].cost)) { + if (player.mana[color] < amt) weights[color] = W.manaNeeded; + } + } + return weights; +} + +// Worst skull damage the opponent could land with one swap on this board +// (ignores cascades/refills — a deliberate, cheap underestimate). +function estimateThreat(board, foe) { + let worst = 0; + for (const { a, b } of legalSwaps(board)) { + let dmg = 0; + for (const g of previewSwapRuns(board, a, b)) { + if (g.cls !== 'skull') continue; + for (const k of g.keys) { + const cell = board[Math.floor(k / SIZE)][k % SIZE]; + // the swapped-in cell may differ; close enough for a threat estimate + dmg += cell?.type === 'skull5' ? 1 + SKULL5_BONUS : 1; + } + if (foe.status.skullBuff > 0) dmg += foe.status.skullBuff; + } + if (dmg > worst) worst = dmg; + } + return worst; +} + +export function chooseAction(ai, match, pIdx) { + const me = match.players[pIdx]; + const foe = match.players[1 - pIdx]; + const k = ai.knobs; + const weights = manaWeights(me); + const missingHp = me.maxHp - me.hp; + + const evalMetrics = (m) => { + if (m.won) return W.win; + let score = W.damage * m.damage + (m.keptTurn ? W.keptTurn : 0); + score += W.heal * Math.min(m.healed, missingHp); + const after = m.sim.players[pIdx].mana; + for (const color of MANA_COLORS) { + score += weights[color] * (after[color] - me.mana[color]); + } + // foe mana removed (drain/steal) is also a win + const foeAfter = m.sim.players[1 - pIdx].mana; + for (const color of MANA_COLORS) { + score += W.drain * Math.max(0, foe.mana[color] - foeAfter[color]); + } + if (k.oppAware && !m.keptTurn) { + score -= W.threat * estimateThreat(m.sim.board, m.sim.players[1 - pIdx]); + } + return score; + }; + + const options = []; + for (const { a, b } of legalSwaps(match.board)) { + let total = 0, n = 0; + for (let s = 0; s < k.samples; s++) { + const m = simulateSwap(match, pIdx, a, b, 1 + Math.floor(ai.rng() * 0x7fffffff)); + if (!m) break; + total += evalMetrics(m); + n++; + } + if (n) options.push({ type: 'swap', a, b, score: total / n }); + } + + if (k.spellIQ > 0) { + for (const spellId of me.spells) { + if (!canAfford(me, spellId)) continue; + const m = simulateSpell(match, pIdx, spellId, 1 + Math.floor(ai.rng() * 0x7fffffff)); + if (!m) continue; + let score = evalMetrics(m); + for (const fx of SPELLS[spellId].effects) { + if (fx.kind === 'stun') score += W.stun; + if (fx.kind === 'buffSkullDamage') score += W.buffPerPoint * fx.amount; + if (fx.kind === 'stealMana') score += (W.steal - W.drain) * fx.amount; + } + options.push({ type: 'spell', spellId, score }); + } + } + + if (!options.length) return null; + options.sort((x, y) => y.score - x.score); + + let pick = options[0]; + // Eager casters love the feel of magic more than the math of it. + if (k.spellIQ === 1 && pick.type !== 'spell') { + const spell = options.find((o) => o.type === 'spell'); + if (spell && ai.rng() < 0.7) pick = spell; + } + // Blunder: pick from the top 5 instead of the top 1 — but a high-skill AI + // never fumbles away a winning move. + if (ai.rng() < k.blunder && !(options[0].score >= W.win && ai.skill >= 9)) { + pick = options[Math.floor(ai.rng() * Math.min(options.length, 5))]; + } + return pick; +} diff --git a/public/src/games/jewelquest/JewelQuestData.js b/public/src/games/jewelquest/JewelQuestData.js new file mode 100644 index 0000000..b6df10b --- /dev/null +++ b/public/src/games/jewelquest/JewelQuestData.js @@ -0,0 +1,164 @@ +// Jewel Quest — class and spell definitions (pure data, no dependencies). +// Spell slots 1-3 are starters; slot 4 unlocks after ladder milestone 1, +// slot 5 after milestone 2 (milestones live in /data/jewelquest.json). +// +// Effect vocabulary (resolved in JewelQuestLogic.js): +// { kind:'damage', amount } +// { kind:'heal', amount } +// { kind:'drainMana', amount, color:'largest'| } +// { kind:'stealMana', amount, color:'largest'| } +// { kind:'destroyGems', selector } selector: { mode:'random', count } | +// { mode:'color', color } | { mode:'skulls', harmless? } | +// { mode:'column' } | { mode:'row' } +// { kind:'transformGems', from:'random'|, to, count|'all' } +// { kind:'buffSkullDamage', amount } (lasts the battle) +// { kind:'stun' } (opponent skips next turn) + +export const SPELLS = { + // ── Knight — skulls and raw damage ───────────────────────────────────────── + shieldBash: { + name: 'Shield Bash', cost: { red: 4 }, + effects: [{ kind: 'damage', amount: 3 }], + desc: 'Slam your shield into the enemy for 3 damage.', + }, + rallyingCry: { + name: 'Rallying Cry', cost: { yellow: 6 }, + effects: [{ kind: 'heal', amount: 6 }], + desc: 'Steel your resolve and recover 6 life.', + }, + cleave: { + name: 'Cleave', cost: { red: 8, yellow: 4 }, + effects: [{ kind: 'damage', amount: 8 }], + desc: 'A mighty two-handed blow for 8 damage.', + }, + skullForge: { + name: 'Skull Forge', cost: { red: 10 }, + effects: [{ kind: 'transformGems', from: 'random', to: 'skull', count: 4 }], + desc: 'Forge 4 random gems into skulls.', + }, + crusadersWrath: { + name: "Crusader's Wrath", cost: { red: 12, yellow: 8 }, + effects: [{ kind: 'buffSkullDamage', amount: 2 }, { kind: 'damage', amount: 8 }], + desc: 'Deal 8 damage; your skull matches deal +2 for the rest of the battle.', + }, + + // ── Sorcerer — big mana, big bursts ──────────────────────────────────────── + spark: { + name: 'Spark', cost: { blue: 3 }, + effects: [{ kind: 'damage', amount: 3 }], + desc: 'A crackle of arcane energy for 3 damage.', + }, + arcaneFunnel: { + name: 'Arcane Funnel', cost: { blue: 7 }, + effects: [{ kind: 'destroyGems', selector: { mode: 'column' } }], + desc: 'Destroy a random column — you collect all its mana.', + }, + fireball: { + name: 'Fireball', cost: { red: 12 }, + effects: [{ kind: 'damage', amount: 13 }], + desc: 'A roaring blast of flame for 13 damage.', + }, + transmute: { + name: 'Transmute', cost: { blue: 10 }, + effects: [{ kind: 'transformGems', from: 'yellow', to: 'blue', count: 'all' }], + desc: 'Turn every yellow gem on the board blue.', + }, + meteorStorm: { + name: 'Meteor Storm', cost: { red: 14, blue: 10 }, + effects: [ + { kind: 'destroyGems', selector: { mode: 'random', count: 8 } }, + { kind: 'damage', amount: 6 }, + ], + desc: 'Rain ruin: destroy 8 random gems and deal 6 damage.', + }, + + // ── Druid — healing and board control ────────────────────────────────────── + regrowth: { + name: 'Regrowth', cost: { green: 5 }, + effects: [{ kind: 'heal', amount: 8 }], + desc: 'Soothing vines restore 8 life.', + }, + thornLash: { + name: 'Thorn Lash', cost: { green: 6 }, + effects: [{ kind: 'damage', amount: 5 }], + desc: 'A whip of thorns for 5 damage.', + }, + entangle: { + name: 'Entangle', cost: { green: 9 }, + effects: [{ kind: 'stun' }], + desc: 'Roots bind your foe — they lose their next turn.', + }, + verdantBloom: { + name: 'Verdant Bloom', cost: { green: 10, blue: 4 }, + effects: [{ kind: 'transformGems', from: 'random', to: 'green', count: 5 }], + desc: 'Bloom 5 random gems into green mana.', + }, + naturesBalance: { + name: "Nature's Balance", cost: { green: 12, yellow: 8 }, + effects: [ + { kind: 'heal', amount: 10 }, + { kind: 'destroyGems', selector: { mode: 'skulls', harmless: true } }, + ], + desc: 'Restore 10 life and harmlessly scatter every skull on the board.', + }, + + // ── Assassin — debuffs and theft ─────────────────────────────────────────── + poisonDart: { + name: 'Poison Dart', cost: { green: 4 }, + effects: [ + { kind: 'damage', amount: 3 }, + { kind: 'drainMana', amount: 3, color: 'largest' }, + ], + desc: 'Deal 3 damage and drain 3 of the enemy\'s deepest mana pool.', + }, + pickpocket: { + name: 'Pickpocket', cost: { blue: 5 }, + effects: [{ kind: 'stealMana', amount: 6, color: 'largest' }], + desc: 'Steal 6 mana from the enemy\'s deepest pool.', + }, + backstab: { + name: 'Backstab', cost: { green: 8, blue: 4 }, + effects: [{ kind: 'damage', amount: 9 }], + desc: 'Strike from the shadows for 9 damage.', + }, + smokeBomb: { + name: 'Smoke Bomb', cost: { blue: 9 }, + effects: [{ kind: 'stun' }], + desc: 'Vanish in smoke — the enemy loses their next turn.', + }, + deathMark: { + name: 'Death Mark', cost: { green: 10, blue: 6 }, + effects: [ + { kind: 'buffSkullDamage', amount: 3 }, + { kind: 'drainMana', amount: 6, color: 'largest' }, + ], + desc: 'Drain 6 enemy mana; your skull matches deal +3 for the rest of the battle.', + }, +}; + +export const CLASSES = { + knight: { + name: 'Knight', + colors: ['red', 'yellow'], + blurb: 'A front-line bruiser. Skulls hit harder, blades hit hardest.', + spells: ['shieldBash', 'rallyingCry', 'cleave', 'skullForge', 'crusadersWrath'], + }, + sorcerer: { + name: 'Sorcerer', + colors: ['blue', 'red'], + blurb: 'Hoards mana, then erases the board — and your hit points.', + spells: ['spark', 'arcaneFunnel', 'fireball', 'transmute', 'meteorStorm'], + }, + druid: { + name: 'Druid', + colors: ['green', 'yellow'], + blurb: 'Outlasts everything. Heals wounds and bends the board to nature.', + spells: ['regrowth', 'thornLash', 'entangle', 'verdantBloom', 'naturesBalance'], + }, + assassin: { + name: 'Assassin', + colors: ['green', 'blue'], + blurb: 'Wins ugly: stolen mana, lost turns, and a knife you never saw.', + spells: ['poisonDart', 'pickpocket', 'backstab', 'smokeBomb', 'deathMark'], + }, +}; diff --git a/public/src/games/jewelquest/JewelQuestGame.js b/public/src/games/jewelquest/JewelQuestGame.js new file mode 100644 index 0000000..7d6df5d --- /dev/null +++ b/public/src/games/jewelquest/JewelQuestGame.js @@ -0,0 +1,1188 @@ +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 { + SIZE, MANA_COLORS, MANA_CAP, + createMatch, applySwap, castSpell, canAfford, playerLoadout, +} from './JewelQuestLogic.js'; +import { CLASSES, SPELLS } from './JewelQuestData.js'; +import { createAI, chooseAction } from './JewelQuestAI.js'; + +const CELL = 72; +const BOARD_W = CELL * SIZE; // 576 +const BOARD_LEFT = (GAME_WIDTH - BOARD_W) / 2; // 672 +const BOARD_TOP = 230; + +const FELT = 0x101626; +const FRAME = 0x0a1020; +const CELLBG = 0x182238; +const GRIDLN = 0x243352; +const GEM_INT = { red: 0xe04444, green: 0x2ecc71, blue: 0x3f8efc, yellow: 0xf1c40f }; +const GEM_HEX = { red: '#e04444', green: '#2ecc71', blue: '#3f8efc', yellow: '#f1c40f' }; + +const PANEL_X = [320, GAME_WIDTH - 320]; // player left, enemy right + +const D = { felt: -2, frame: -1, grid: 0, cells: 5, fx: 12, ui: 30, overlay: 60, overlayUI: 62 }; +const REPLAY_DELAY = { + swap: 240, clear: 340, fall: 180, refill: 230, + spell: 750, damage: 380, heal: 380, mana: 380, buff: 380, stun: 550, + destroy: 340, transform: 340, + extraTurn: 650, skipTurn: 650, shuffle: 550, turnEnd: 80, gameOver: 0, +}; + +export default class JewelQuestGame extends Phaser.Scene { + constructor() { super('JewelQuestGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'jewelquest', name: 'Jewel Quest' }; + this.config = { playerBaseHp: 50, milestones: [], levels: [] }; + this.bank = []; + this.roster = []; + this.levelsCompleted = 0; + this.canPersist = true; + this.view = 'select'; + this.portraits = []; // active Portrait handles (DOM-backed, not in layer) + this.match = null; + this.overlayUp = false; + this.playerClass = null; + } + + async create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt); + + const raw = this.cache.json.get('jewelquest'); + this.config = raw ?? this.config; + 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/jewelquest/progress'); + this.levelsCompleted = res?.levelsCompleted ?? 0; + } catch (_) { + this.canPersist = false; + this.levelsCompleted = 0; + } + + try { + const saved = localStorage.getItem('jewelquest.class'); + if (saved && CLASSES[saved]) this.playerClass = saved; + } catch (_) { /* private mode */ } + + this.makeTextures(); + this.layer = this.add.container(0, 0); + if (this.playerClass) this.showLevelSelect(); + else this.showClassSelect(); + } + + opponentFor(levelDef) { + const opp = this.roster.find((o) => o.id === levelDef.opponentId); + if (opp) return opp; + console.warn(`jewelquest: opponent '${levelDef.opponentId}' not in roster; using stub`); + return { id: levelDef.opponentId, spriteIndex: 0, name: levelDef.opponentId, bio: '', speech: {} }; + } + + loadout() { return playerLoadout(this.config, this.levelsCompleted); } + + unlockLevelForSlot(slot) { + const m = (this.config.milestones ?? []).find((x) => x.unlockSpellSlot === slot); + return m ? m.afterLevel : null; + } + + // ── Generated gem textures ────────────────────────────────────────────────── + + makeTextures() { + if (this.textures.exists('jq-gem-red')) return; + + for (const color of MANA_COLORS) { + const g = this.make.graphics({ add: false }); + g.fillStyle(GEM_INT[color], 1); + g.fillRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + g.fillStyle(0xffffff, 0.30); + g.fillRoundedRect(11, 9, CELL - 22, 17, 8); + g.lineStyle(2, 0x000000, 0.35); + g.strokeRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + g.generateTexture(`jq-gem-${color}`, CELL, CELL); + g.destroy(); + } + + const drawSkullFace = (g) => { + const cx = CELL / 2; + g.fillStyle(0xe8edf2, 1); + g.fillCircle(cx, 30, 17); + g.fillRoundedRect(cx - 11, 38, 22, 14, 5); + g.fillStyle(0x232b3a, 1); + g.fillCircle(cx - 7, 28, 5); + g.fillCircle(cx + 7, 28, 5); + g.fillTriangle(cx, 34, cx - 3.5, 40, cx + 3.5, 40); + g.lineStyle(2, 0x232b3a, 1); + g.lineBetween(cx - 4, 44, cx - 4, 51); + g.lineBetween(cx, 44, cx, 51); + g.lineBetween(cx + 4, 44, cx + 4, 51); + }; + + let g = this.make.graphics({ add: false }); + g.fillStyle(0x222a39, 1); + g.fillRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + g.lineStyle(2, 0x000000, 0.35); + g.strokeRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + drawSkullFace(g); + g.generateTexture('jq-skull', CELL, CELL); + g.destroy(); + + g = this.make.graphics({ add: false }); + g.fillStyle(0x3a1822, 1); + g.fillRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + g.lineStyle(3, GEM_INT.red, 0.95); + g.strokeRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + drawSkullFace(g); + g.generateTexture('jq-skull5', CELL, CELL); + g.destroy(); + + g = this.make.graphics({ add: false }); + g.fillStyle(0x1b2230, 1); + g.fillRoundedRect(4, 4, CELL - 8, CELL - 8, 16); + const cx = CELL / 2; + const quads = [ + [GEM_INT.red, -Math.PI / 2, 0], + [GEM_INT.yellow, 0, Math.PI / 2], + [GEM_INT.green, Math.PI / 2, Math.PI], + [GEM_INT.blue, Math.PI, Math.PI * 1.5], + ]; + for (const [color, a0, a1] of quads) { + g.fillStyle(color, 0.95); + g.slice(cx, cx, 24, a0, a1, false); + g.fillPath(); + } + g.lineStyle(3, 0xffffff, 0.8); + g.strokeCircle(cx, cx, 24); + g.generateTexture('jq-wild', CELL, CELL); + g.destroy(); + + g = this.make.graphics({ add: false }); + g.lineStyle(4, 0xffffff, 0.95); + g.strokeRoundedRect(3, 3, CELL - 6, CELL - 6, 16); + g.generateTexture('jq-select', CELL, CELL); + g.destroy(); + } + + textureFor(cell) { + if (cell.type === 'skull') return 'jq-skull'; + if (cell.type === 'skull5') return 'jq-skull5'; + if (cell.type === 'wild') return 'jq-wild'; + return `jq-gem-${cell.type}`; + } + + // ── View management ───────────────────────────────────────────────────────── + + clearLayer() { + for (const p of this.portraits) { try { p.destroy(); } catch (_) {} } + this.portraits = []; + if (this.aiTimer) { this.aiTimer.remove(false); this.aiTimer = null; } + this.aiScheduled = false; + this.replayQueue = []; + this.selected = null; + this.dragFrom = null; + this.layer.removeAll(true); + this.cellSprites = null; + this.spellButtons = null; + } + + costText(spell) { + return Object.entries(spell.cost).map(([color, amt]) => `${amt} ${color}`).join(' + '); + } + + // Small colored cost chips (circle + number) appended right of x. + addCostChips(objs, spell, x, y, scale = 1) { + let cx = x; + for (const [color, amt] of Object.entries(spell.cost)) { + const r = 13 * scale; + const chip = this.add.circle(cx, y, r, GEM_INT[color]).setStrokeStyle(2, 0x000000, 0.4); + const num = this.add.text(cx, y, String(amt), { + fontFamily: 'Righteous', fontSize: `${Math.round(15 * scale)}px`, color: '#10131a', + }).setOrigin(0.5); + objs.push(chip, num); + cx += r * 2 + 8 * scale; + } + return cx; + } + + // ── Class select ──────────────────────────────────────────────────────────── + + showClassSelect() { + this.view = 'class'; + this.overlayUp = false; + this.match = null; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 80, 'JEWEL QUEST', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 138, 'Choose your champion. Match gems for mana, match skulls for damage, and spend mana on spells.', { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + const loadout = this.loadout(); + const ids = Object.keys(CLASSES); + const CARD_W = 430; + const CARD_H = 660; + const GAP = 26; + const totalW = ids.length * CARD_W + (ids.length - 1) * GAP; + const left = cx - totalW / 2 + CARD_W / 2; + const cy = 540; + + ids.forEach((id, i) => { + const cls = CLASSES[id]; + const x = left + i * (CARD_W + GAP); + const objs = []; + + const card = this.add.rectangle(x, cy, CARD_W, CARD_H, 0x16202e) + .setStrokeStyle(3, this.playerClass === id ? COLORS.gold : 0x2a3744, 1); + objs.push(card); + + const name = this.add.text(x, cy - CARD_H / 2 + 48, cls.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, + }).setOrigin(0.5); + objs.push(name); + + cls.colors.forEach((color, j) => { + objs.push(this.add.circle(x - 22 + j * 44, cy - CARD_H / 2 + 92, 14, GEM_INT[color]) + .setStrokeStyle(2, 0x000000, 0.4)); + }); + + const blurb = this.add.text(x, cy - CARD_H / 2 + 148, cls.blurb, { + fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.mutedHex, + align: 'center', wordWrap: { width: CARD_W - 50 }, lineSpacing: 4, + }).setOrigin(0.5); + objs.push(blurb); + + cls.spells.forEach((spellId, s) => { + const spell = SPELLS[spellId]; + const unlocked = s < loadout.spellCount; + const sy = cy - CARD_H / 2 + 208 + s * 78; + const sname = this.add.text(x - CARD_W / 2 + 26, sy, spell.name, { + fontFamily: 'Righteous', fontSize: '22px', + color: unlocked ? COLORS.textHex : '#54606b', + }).setOrigin(0, 0.5); + objs.push(sname); + const chips = []; + this.addCostChips(chips, spell, x - CARD_W / 2 + 40, sy + 28, 0.85); + if (!unlocked) chips.forEach((c) => c.setAlpha(0.4)); + objs.push(...chips); + const note = unlocked + ? this.add.text(x + CARD_W / 2 - 26, sy + 28, spell.desc, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, + align: 'right', wordWrap: { width: CARD_W - 200 }, + }).setOrigin(1, 0.5) + : this.add.text(x + CARD_W / 2 - 26, sy + 28, `Unlocks after Level ${this.unlockLevelForSlot(s + 1) ?? '?'}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#54606b', + }).setOrigin(1, 0.5); + if (!unlocked) sname.setAlpha(0.6); + objs.push(note); + }); + + const pick = new Button(this, x, cy + CARD_H / 2 - 50, this.playerClass === id ? 'Selected' : `Play ${cls.name}`, () => { + this.playerClass = id; + try { localStorage.setItem('jewelquest.class', id); } catch (_) {} + playSound(this, SFX.CARD_PLACE); + this.showLevelSelect(); + }, { width: CARD_W - 80, height: 56, fontSize: 24 }); + objs.push(pick); + + card.setInteractive({ useHandCursor: true }); + card.on('pointerover', () => card.setStrokeStyle(4, COLORS.gold, 1)); + card.on('pointerout', () => card.setStrokeStyle(3, this.playerClass === id ? COLORS.gold : 0x2a3744, 1)); + + this.layer.add(objs); + }); + + const back = new Button(this, cx, GAME_HEIGHT - 70, 'Back', () => { + if (this.playerClass) this.showLevelSelect(); + else this.scene.start('GameMenu'); + }, { variant: 'ghost', width: 180, height: 54, fontSize: 22 }); + this.layer.add(back); + } + + // ── Level select ──────────────────────────────────────────────────────────── + + showLevelSelect() { + this.view = 'select'; + this.overlayUp = false; + this.match = null; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 78, 'JEWEL QUEST', { + fontFamily: 'Righteous', fontSize: '60px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 130, 'Match 3+ gems to bank mana, match skulls to deal damage, and cast spells to break your rival.', { + 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/jewelquest.json', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center', + }).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 loadout = this.loadout(); + const cls = CLASSES[this.playerClass]; + const nextMs = (this.config.milestones ?? []).find((m) => this.levelsCompleted < m.afterLevel); + const strip = [ + `Class: ${cls.name}`, + `Max HP ${loadout.maxHp}`, + `Spells ${loadout.spellCount}/5`, + nextMs ? `Next bonus after Level ${nextMs.afterLevel}` : 'All bonuses earned', + ].join(' • '); + const prog = this.add.text(cx, 176, `Defeated ${this.levelsCompleted} / ${this.bank.length}`, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5); + const loadoutText = this.add.text(cx, 216, strip, { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.layer.add([prog, loadoutText]); + + const COLS = 10; + const TILE = 128; + const GAP = 16; + const gridW = COLS * TILE + (COLS - 1) * GAP; + const left = cx - gridW / 2 + TILE / 2; + const top = 330; + + this.bank.forEach((lv, i) => { + const col = i % COLS; + const row = Math.floor(i / COLS); + const x = left + col * (TILE + GAP); + const y = top + row * (TILE + GAP + 36); + const level = lv.level; + const cleared = level <= this.levelsCompleted; + const playable = level <= nextLevel; + const opp = this.opponentFor(lv); + + const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b; + const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744; + const tile = this.add.rectangle(x, y, TILE, TILE + 28, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1); + const num = this.add.text(x, y - TILE / 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 + TILE / 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', () => 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 }); + const changeClass = new Button(this, GAME_WIDTH - 210, GAME_HEIGHT - 78, 'Change Class', () => this.showClassSelect(), + { variant: 'ghost', width: 260, height: 58, fontSize: 22 }); + this.layer.add([resume, back, reset, changeClass]); + + 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; + const 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 title = this.add.text(cx, cy - 92, 'Reset Progress?', { + fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const msg = this.add.text(cx, cy - 14, + 'This clears every rival you have beaten — and the spells\nand HP you earned — back to Level 1. This cannot be undone.', { + 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/jewelquest/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, title, msg, yes, no]); + } + + // ── Pre-battle 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); + const vs = this.add.text(cx, 560, 'VS', { + fontFamily: 'Righteous', fontSize: '40px', color: COLORS.mutedHex, + }).setOrigin(0.5).setAlpha(0.6); + this.layer.add([title, vs]); + + this.portraits.push(createOpponentPortrait(this, opp, cx, 360, 150, D.ui, { playIntro: true })); + + const name = this.add.text(cx, 632, opp.name, { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.textHex, + }).setOrigin(0.5); + const bio = this.add.text(cx, 692, opp.bio ?? '', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5); + const tagline = this.add.text(cx, 740, lv.tagline ?? '', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, fontStyle: 'italic', + }).setOrigin(0.5); + + const stars = '★'.repeat(Math.ceil(lv.skill / 2)) + '☆'.repeat(5 - Math.ceil(lv.skill / 2)); + const enemyCls = CLASSES[lv.class]?.name ?? lv.class; + const statText = this.add.text(cx, 800, `Skill ${stars} HP ❤ ${lv.hp} Class ${enemyCls}`, { + fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex, + }).setOrigin(0.5); + this.layer.add([name, bio, tagline, statText]); + + 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([fight, back]); + } + + // ── Battle setup ──────────────────────────────────────────────────────────── + + startBattle(level) { + const lv = this.bank.find((l) => l.level === level); + if (!lv || !this.playerClass) return; + this.view = 'battle'; + this.overlayUp = false; + this.level = level; + this.levelDef = lv; + this.opponent = this.opponentFor(lv); + this.clearLayer(); + + const loadout = this.loadout(); + const seed = (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0; + this.match = createMatch({ + seed, + classes: [this.playerClass, lv.class], + hp: [loadout.maxHp, lv.hp], + spellCounts: [loadout.spellCount, lv.spellCount ?? 5], + weights: lv.weights ?? null, + }); + this.enemyAI = createAI({ skill: lv.skill, seed: seed ^ 0x9e3779b9 }); + this.replayQueue = []; + this.replayTimer = 0; + this.aiScheduled = false; + this.matchEnded = false; + this.selected = null; + this.dragFrom = null; + + this.drawBattleChrome(); + this.renderBoardCells(this.match.board); + this.updateMetersFrom(null); + this.refreshSpellButtons(); + this.showTurnBanner(); + } + + cellXY(r, c) { + return { + x: BOARD_LEFT + c * CELL + CELL / 2, + y: BOARD_TOP + r * CELL + CELL / 2, + }; + } + + drawBattleChrome() { + const cx = GAME_WIDTH / 2; + const hud = this.add.text(cx, 52, `Level ${this.level} — vs ${this.opponent.name}`, { + fontFamily: 'Righteous', fontSize: '36px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(hud); + + // board frame + grid + const g = this.add.graphics().setDepth(D.frame); + g.fillStyle(FRAME, 1); + g.fillRoundedRect(BOARD_LEFT - 16, BOARD_TOP - 16, BOARD_W + 32, BOARD_W + 32, 14); + g.fillStyle(CELLBG, 1); + g.fillRect(BOARD_LEFT, BOARD_TOP, BOARD_W, BOARD_W); + this.layer.add(g); + + const grid = this.add.graphics().setDepth(D.grid); + grid.lineStyle(1, GRIDLN, 0.8); + for (let c = 0; c <= SIZE; c++) grid.lineBetween(BOARD_LEFT + c * CELL, BOARD_TOP, BOARD_LEFT + c * CELL, BOARD_TOP + BOARD_W); + for (let r = 0; r <= SIZE; r++) grid.lineBetween(BOARD_LEFT, BOARD_TOP + r * CELL, BOARD_LEFT + BOARD_W, BOARD_TOP + r * CELL); + this.layer.add(grid); + + // turn banner + this.turnText = this.add.text(cx, 118, '', { + fontFamily: 'Righteous', fontSize: '32px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(this.turnText); + + // selection highlight + this.selectImg = this.add.image(0, 0, 'jq-select').setDepth(D.cells + 2).setVisible(false); + this.layer.add(this.selectImg); + this.tweens.add({ targets: this.selectImg, alpha: 0.45, duration: 420, yoyo: true, repeat: -1 }); + + // input zone over the board (tap-tap or drag to swap) + const zone = this.add.zone(BOARD_LEFT + BOARD_W / 2, BOARD_TOP + BOARD_W / 2, BOARD_W, BOARD_W) + .setInteractive({ useHandCursor: true }).setDepth(D.ui); + zone.on('pointerdown', (pointer) => this.onBoardDown(pointer)); + zone.on('pointermove', (pointer) => this.onBoardMove(pointer)); + zone.on('pointerup', (pointer) => this.onBoardUp(pointer)); + this.layer.add(zone); + + // side panels + this.hpBars = []; + this.hpTexts = []; + this.manaBars = []; + this.manaTexts = []; + this.panelNames = []; + for (const i of [0, 1]) this.drawPanel(i); + + this.portraits.push(createPlayerPortrait(this, PANEL_X[0], 240, 72, D.ui, 'JewelQuestGame')); + this.oppPortrait = createOpponentPortrait(this, this.opponent, PANEL_X[1], 240, 72, D.ui, { playIntro: false }); + this.portraits.push(this.oppPortrait); + + this.drawSpellPanel(); + + // callout + this.calloutText = this.add.text(cx, 540, '', { + fontFamily: 'Righteous', fontSize: '54px', color: COLORS.goldHex, stroke: '#000000', strokeThickness: 6, + }).setOrigin(0.5).setDepth(D.fx).setAlpha(0); + this.layer.add(this.calloutText); + + const quit = new Button(this, 140, GAME_HEIGHT - 50, 'Levels', () => this.showLevelSelect(), + { variant: 'ghost', width: 180, height: 52, fontSize: 22 }); + this.layer.add(quit); + } + + drawPanel(i) { + const px = PANEL_X[i]; + const name = this.add.text(px, 332, i === 0 ? 'YOU' : this.opponent.name.toUpperCase(), { + fontFamily: 'Righteous', fontSize: '26px', color: i === 0 ? '#5bc0de' : COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(name); + this.panelNames.push(name); + + // HP bar + const barW = 380; + const hpBg = this.add.rectangle(px, 376, barW, 28, 0x0a1020).setStrokeStyle(2, 0x2a3744, 1).setDepth(D.ui); + const hpFill = this.add.rectangle(px - barW / 2 + 2, 376, barW - 4, 22, 0x2ecc71) + .setOrigin(0, 0.5).setDepth(D.ui + 1); + const hpText = this.add.text(px, 376, '', { + fontFamily: 'Righteous', fontSize: '18px', color: '#ffffff', stroke: '#000000', strokeThickness: 3, + }).setOrigin(0.5).setDepth(D.ui + 2); + this.layer.add([hpBg, hpFill, hpText]); + this.hpBars.push({ fill: hpFill, w: barW - 4 }); + this.hpTexts.push(hpText); + + // mana rows + const rows = {}; + const texts = {}; + MANA_COLORS.forEach((color, j) => { + const y = 424 + j * 38; + const swatch = this.add.circle(px - barW / 2 + 12, y, 11, GEM_INT[color]).setStrokeStyle(2, 0x000000, 0.4).setDepth(D.ui); + const bg = this.add.rectangle(px + 14, y, barW - 80, 18, 0x0a1020).setStrokeStyle(1, 0x2a3744, 1).setDepth(D.ui); + const fill = this.add.rectangle(px + 14 - (barW - 80) / 2 + 1, y, 0, 13, GEM_INT[color]) + .setOrigin(0, 0.5).setDepth(D.ui + 1); + const t = this.add.text(px + barW / 2 - 4, y, '0/25', { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.layer.add([swatch, bg, fill, t]); + rows[color] = { fill, w: barW - 82 }; + texts[color] = t; + }); + this.manaBars.push(rows); + this.manaTexts.push(texts); + } + + drawSpellPanel() { + this.spellButtons = []; + this.enemySpellTexts = []; + + // player spell buttons (left) + const px = PANEL_X[0]; + const W = 460; + const cls = CLASSES[this.playerClass]; + cls.spells.forEach((spellId, s) => { + const spell = SPELLS[spellId]; + const y = 626 + s * 80; + const unlocked = s < this.match.players[0].spells.length; + const objs = []; + const bg = this.add.rectangle(px, y, W, 68, 0x1a2536) + .setStrokeStyle(2, 0x2a3744, 1).setDepth(D.ui); + const name = this.add.text(px - W / 2 + 18, y - 14, spell.name, { + fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, + }).setOrigin(0, 0.5).setDepth(D.ui + 1); + objs.push(bg, name); + if (unlocked) { + const chips = []; + this.addCostChips(chips, spell, px - W / 2 + 30, y + 17, 0.8); + chips.forEach((c) => c.setDepth(D.ui + 1)); + const desc = this.add.text(px + W / 2 - 14, y + 17, spell.desc, { + fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex, + align: 'right', wordWrap: { width: W - 190 }, + }).setOrigin(1, 0.5).setDepth(D.ui + 1); + objs.push(...chips, desc); + bg.setInteractive({ useHandCursor: true }); + bg.on('pointerup', () => this.tryCast(spellId)); + } else { + const lockNote = this.add.text(px - W / 2 + 18, y + 17, + `🔒 Unlocks after Level ${this.unlockLevelForSlot(s + 1) ?? '?'}`, { + fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#54606b', + }).setOrigin(0, 0.5).setDepth(D.ui + 1); + objs.push(lockNote); + name.setColor('#54606b'); + } + this.layer.add(objs); + this.spellButtons.push({ spellId, bg, objs, unlocked, index: s }); + }); + + // enemy spell list (right, display only) + const ex = PANEL_X[1]; + const enemy = this.match.players[1]; + const header = this.add.text(ex, 596, 'SPELLS', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + this.layer.add(header); + enemy.spells.forEach((spellId, s) => { + const spell = SPELLS[spellId]; + const y = 636 + s * 56; + const bg = this.add.rectangle(ex, y, 420, 46, 0x161e2c) + .setStrokeStyle(1, 0x2a3744, 1).setDepth(D.ui); + const name = this.add.text(ex - 192, y, spell.name, { + fontFamily: 'Righteous', fontSize: '19px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.ui + 1); + const chips = []; + this.addCostChips(chips, spell, ex + 60, y, 0.7); + chips.forEach((c) => c.setDepth(D.ui + 1).setAlpha(0.8)); + this.layer.add([bg, name, ...chips]); + this.enemySpellTexts.push({ spellId, bg, name }); + }); + } + + // ── Battle input ──────────────────────────────────────────────────────────── + + canAct() { + return this.view === 'battle' && this.match && !this.match.over && !this.overlayUp + && this.match.turn === 0 && this.replayQueue.length === 0; + } + + cellFromPointer(pointer) { + const c = Math.floor((pointer.x - BOARD_LEFT) / CELL); + const r = Math.floor((pointer.y - BOARD_TOP) / CELL); + if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) return null; + return { r, c }; + } + + onBoardDown(pointer) { + if (!this.canAct()) return; + const cell = this.cellFromPointer(pointer); + if (!cell) return; + this.dragFrom = { ...cell, x: pointer.x, y: pointer.y }; + } + + onBoardMove(pointer) { + if (!this.dragFrom || !pointer.isDown || !this.canAct()) return; + const dx = pointer.x - this.dragFrom.x; + const dy = pointer.y - this.dragFrom.y; + if (Math.abs(dx) < CELL * 0.4 && Math.abs(dy) < CELL * 0.4) return; + const from = { r: this.dragFrom.r, c: this.dragFrom.c }; + const to = Math.abs(dx) > Math.abs(dy) + ? { r: from.r, c: from.c + Math.sign(dx) } + : { r: from.r + Math.sign(dy), c: from.c }; + this.dragFrom = null; + this.setSelected(null); + if (to.r >= 0 && to.r < SIZE && to.c >= 0 && to.c < SIZE) this.attemptSwap(from, to); + } + + onBoardUp(pointer) { + if (!this.dragFrom) return; + const start = this.dragFrom; + this.dragFrom = null; + if (!this.canAct()) return; + const cell = this.cellFromPointer(pointer); + if (!cell || cell.r !== start.r || cell.c !== start.c) return; // drag handled in move + + if (!this.selected) { + this.setSelected(cell); + playSound(this, SFX.PIECE_CLICK); + return; + } + if (this.selected.r === cell.r && this.selected.c === cell.c) { + this.setSelected(null); + return; + } + const adjacent = Math.abs(this.selected.r - cell.r) + Math.abs(this.selected.c - cell.c) === 1; + if (adjacent) { + const from = this.selected; + this.setSelected(null); + this.attemptSwap(from, cell); + } else { + this.setSelected(cell); + playSound(this, SFX.PIECE_CLICK); + } + } + + setSelected(cell) { + this.selected = cell; + if (cell) { + const { x, y } = this.cellXY(cell.r, cell.c); + this.selectImg.setPosition(x, y).setVisible(true); + } else { + this.selectImg.setVisible(false); + } + } + + attemptSwap(a, b) { + if (!this.canAct()) return; + const res = applySwap(this.match, a, b); + if (!res.legal) { + // bounce the two gems out and back + playSound(this, SFX.MASTERMIND_DENIED ?? SFX.PIECE_CLICK); + const sa = this.cellSprites?.[a.r]?.[a.c]; + const sb = this.cellSprites?.[b.r]?.[b.c]; + const pa = this.cellXY(a.r, a.c); + const pb = this.cellXY(b.r, b.c); + for (const [spr, from, to] of [[sa, pa, pb], [sb, pb, pa]]) { + if (!spr?.img) continue; + this.tweens.add({ + targets: spr.img, + x: from.x + (to.x - from.x) * 0.35, + y: from.y + (to.y - from.y) * 0.35, + duration: 90, yoyo: true, + }); + } + return; + } + playSound(this, SFX.CARD_PLACE); + this.enqueue(res.events); + } + + tryCast(spellId) { + if (!this.canAct()) return; + if (!canAfford(this.match.players[0], spellId)) return; + const res = castSpell(this.match, spellId); + if (!res.legal) return; + this.setSelected(null); + this.enqueue(res.events); + } + + // ── Replay queue / rendering ──────────────────────────────────────────────── + + enqueue(events) { + if (events?.length) this.replayQueue.push(...events); + } + + renderBoardCells(board) { + if (this.cellSprites) { + for (const row of this.cellSprites) { + for (const s of row) { + if (!s) continue; + s.img.destroy(); + s.extras.forEach((e) => e.destroy()); + } + } + } + this.cellSprites = []; + for (let r = 0; r < SIZE; r++) { + const row = []; + for (let c = 0; c < SIZE; c++) { + const cell = board[r][c]; + if (!cell) { row.push(null); continue; } + const { x, y } = this.cellXY(r, c); + const img = this.add.image(x, y, this.textureFor(cell)).setDepth(D.cells); + this.layer.add(img); + const extras = []; + if (cell.type === 'skull5') { + const t = this.add.text(x + 20, y - 18, '5', { + fontFamily: 'Righteous', fontSize: '20px', color: GEM_HEX.red, stroke: '#000000', strokeThickness: 3, + }).setOrigin(0.5).setDepth(D.cells + 1); + this.layer.add(t); + extras.push(t); + } else if (cell.type === 'wild') { + const t = this.add.text(x, y, `×${cell.mult}`, { + fontFamily: 'Righteous', fontSize: '22px', color: '#ffffff', stroke: '#000000', strokeThickness: 4, + }).setOrigin(0.5).setDepth(D.cells + 1); + this.layer.add(t); + extras.push(t); + } + row.push({ img, extras }); + } + this.cellSprites.push(row); + } + } + + flashCells(cells, color = 0xffffff) { + for (const cell of cells ?? []) { + const { x, y } = this.cellXY(cell.r, cell.c); + const fx = this.add.rectangle(x, y, CELL - 6, CELL - 6, color, 0.85).setDepth(D.fx); + this.layer.add(fx); + this.tweens.add({ targets: fx, alpha: 0, scale: 1.35, duration: 280, onComplete: () => fx.destroy() }); + } + } + + floatText(x, y, text, color, toX, toY) { + const t = this.add.text(x, y, text, { + fontFamily: 'Righteous', fontSize: '28px', color, stroke: '#000000', strokeThickness: 4, + }).setOrigin(0.5).setDepth(D.fx); + this.layer.add(t); + this.tweens.add({ + targets: t, x: toX, y: toY, alpha: 0, duration: 700, ease: 'Cubic.easeIn', + onComplete: () => t.destroy(), + }); + } + + groupCentroid(cells) { + let x = 0, y = 0; + for (const cell of cells) { const p = this.cellXY(cell.r, cell.c); x += p.x; y += p.y; } + return { x: x / cells.length, y: y / cells.length }; + } + + processEvent(e) { + switch (e.type) { + case 'swap': + this.renderBoardCells(e.board); + this.flashCells([e.a, e.b], 0xffffff); + break; + case 'clear': { + playSound(this, SFX.MASTERMIND_MATCH ?? SFX.CARD_SHOW); + const actorPanel = PANEL_X[e.actor]; + const foePanel = PANEL_X[1 - e.actor]; + for (const grp of e.groups ?? []) { + const { x, y } = this.groupCentroid(grp.cells); + if (grp.cls === 'skull') { + this.flashCells(grp.cells, GEM_INT.red); + this.floatText(x, y, `-${grp.damage}`, GEM_HEX.red, foePanel, 376); + } else { + this.flashCells(grp.cells, GEM_INT[grp.cls]); + this.floatText(x, y, `+${grp.mana}`, GEM_HEX[grp.cls], actorPanel, 480); + } + } + if (e.cascade >= 2) { + this.showCallout(`${e.cascade}× CASCADE!`); + this.oppPortrait?.playEmotion(e.actor === 0 ? 'upset' : 'happy'); + } + this.renderBoardCells(e.board); + this.updateMetersFrom(e); + break; + } + case 'fall': + case 'refill': + this.renderBoardCells(e.board); + break; + case 'spell': { + playSound(this, SFX.CARD_SHOW); + const caster = e.caster === 0 ? 'YOU' : this.opponent.name.toUpperCase(); + this.showCallout(`${caster}: ${e.name}!`, e.caster === 0 ? '#9be7b4' : '#ff8a8a'); + this.oppPortrait?.playEmotion(e.caster === 0 ? 'upset' : 'happy'); + if (e.caster === 1) this.flashEnemySpell(e.spellId); + this.updateMetersFrom(e); + break; + } + case 'damage': + this.floatText(PANEL_X[e.target], 320, `-${e.amount}`, GEM_HEX.red, PANEL_X[e.target], 376); + this.updateMetersFrom(e); + break; + case 'heal': + this.floatText(PANEL_X[e.target], 320, `+${e.amount}`, '#9be7b4', PANEL_X[e.target], 376); + this.updateMetersFrom(e); + break; + case 'mana': + this.floatText(PANEL_X[e.target], 480, `-${e.amount} ${e.color}`, GEM_HEX[e.color] ?? '#ffffff', PANEL_X[e.target], 520); + this.updateMetersFrom(e); + break; + case 'buff': + this.floatText(PANEL_X[e.target], 320, `⚔ +${e.amount}`, COLORS.goldHex, PANEL_X[e.target], 250); + this.updateMetersFrom(e); + break; + case 'stun': + this.floatText(PANEL_X[e.target], 320, '✦ STUNNED ✦', '#b9a6ff', PANEL_X[e.target], 250); + this.updateMetersFrom(e); + break; + case 'destroy': + playSound(this, SFX.DICE_ROLL); + this.flashCells(e.cells, 0xffe08a); + this.renderBoardCells(e.board); + this.updateMetersFrom(e); + break; + case 'transform': + this.flashCells(e.cells, 0xb9a6ff); + this.renderBoardCells(e.board); + break; + case 'extraTurn': + this.showCallout(e.turn === 0 ? 'EXTRA TURN!' : `${this.opponent.name.toUpperCase()} GOES AGAIN`, + e.turn === 0 ? COLORS.goldHex : '#ff8a8a'); + break; + case 'skipTurn': + this.showCallout(e.skipped === 0 ? 'YOU ARE STUNNED!' : `${this.opponent.name.toUpperCase()} IS STUNNED!`, '#b9a6ff'); + break; + case 'shuffle': + playSound(this, SFX.DICE_ROLL); + this.showCallout('NO MOVES — RESHUFFLE', COLORS.mutedHex); + this.renderBoardCells(e.board); + break; + case 'turnEnd': + this.renderBoardCells(e.board); + this.updateMetersFrom(e); + this.showTurnBanner(e.turn); + break; + case 'gameOver': + this.renderBoardCells(e.board); + this.updateMetersFrom(e); + this.endMatch(); + break; + default: + this.renderBoardCells(e.board); + break; + } + this.refreshSpellButtons(); + return REPLAY_DELAY[e.type] ?? 150; + } + + flashEnemySpell(spellId) { + const entry = this.enemySpellTexts?.find((x) => x.spellId === spellId); + if (!entry) return; + entry.bg.setFillStyle(0x4a2f17); + entry.name.setColor(COLORS.goldHex); + this.time.delayedCall(900, () => { + try { entry.bg.setFillStyle(0x161e2c); entry.name.setColor(COLORS.mutedHex); } catch (_) {} + }); + } + + showCallout(text, color = COLORS.goldHex) { + this.calloutText.setText(text).setColor(color).setAlpha(1).setScale(0.7); + this.tweens.add({ targets: this.calloutText, scale: 1, duration: 140, ease: 'Back.easeOut' }); + this.tweens.add({ targets: this.calloutText, alpha: 0, delay: 850, duration: 300 }); + } + + showTurnBanner(turn = this.match?.turn ?? 0) { + if (!this.turnText) return; + if (this.match?.over) { this.turnText.setText(''); return; } + this.turnText + .setText(turn === 0 ? '— YOUR TURN —' : `— ${this.opponent.name.toUpperCase()}'S TURN —`) + .setColor(turn === 0 ? '#9be7b4' : '#ff8a8a'); + } + + // Meters update from event snapshots during replay (so HP/mana track the + // animation, not the already-resolved engine state). + updateMetersFrom(e) { + const players = e?.players ?? this.match?.players; + if (!players || !this.hpBars) return; + for (const i of [0, 1]) { + const p = players[i]; + const frac = Math.max(0, Math.min(1, p.hp / p.maxHp)); + this.hpBars[i].fill.width = this.hpBars[i].w * frac; + this.hpBars[i].fill.setFillStyle(frac > 0.5 ? 0x2ecc71 : frac > 0.25 ? 0xf1c40f : 0xe04444); + const buff = p.status?.skullBuff > 0 ? ` ⚔+${p.status.skullBuff}` : ''; + this.hpTexts[i].setText(`${p.hp} / ${p.maxHp}${buff}`); + for (const color of MANA_COLORS) { + const bar = this.manaBars[i][color]; + bar.fill.width = bar.w * Math.max(0, Math.min(1, p.mana[color] / MANA_CAP)); + this.manaTexts[i][color].setText(`${p.mana[color]}/${MANA_CAP}`); + } + } + } + + refreshSpellButtons() { + if (!this.spellButtons || !this.match) return; + const me = this.match.players[0]; + const active = this.canAct(); + for (const btn of this.spellButtons) { + if (!btn.unlocked) { + btn.bg.setFillStyle(0x131a26).setStrokeStyle(2, 0x222d3c, 1); + btn.objs.forEach((o) => o.setAlpha?.(0.55)); + continue; + } + const affordable = canAfford(me, btn.spellId); + const ready = active && affordable; + btn.bg.setFillStyle(ready ? 0x21385a : 0x1a2536) + .setStrokeStyle(2, ready ? COLORS.gold : 0x2a3744, 1); + btn.objs.forEach((o) => o.setAlpha?.(affordable ? 1 : 0.55)); + } + } + + // ── Main loop ─────────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.view !== 'battle' || !this.match || this.overlayUp) return; + + if (this.replayQueue.length) { + this.replayTimer -= delta; + if (this.replayTimer <= 0) { + const e = this.replayQueue.shift(); + this.replayTimer = this.processEvent(e); + } + return; + } + + if (this.match.over) { this.endMatch(); return; } + + if (this.match.turn === 1 && !this.aiScheduled) { + this.aiScheduled = true; + this.aiTimer = this.time.delayedCall(this.enemyAI.knobs.thinkMs, () => { + this.aiScheduled = false; + this.aiTimer = null; + if (this.view !== 'battle' || !this.match || this.match.over || this.match.turn !== 1) return; + if (this.replayQueue.length) return; // shouldn't happen; retried next idle frame + const action = chooseAction(this.enemyAI, this.match, 1); + if (!action) return; + const res = action.type === 'spell' + ? castSpell(this.match, action.spellId) + : applySwap(this.match, action.a, action.b); + if (res.legal) this.enqueue(res.events); + }); + } + } + + // ── End of match ──────────────────────────────────────────────────────────── + + endMatch() { + if (this.matchEnded) return; + this.matchEnded = true; + const won = this.match.winner === 0; + const p = this.match.players[0]; + + this.oppPortrait?.playEmotion(won ? 'upset' : 'happy'); + playSound(this, won ? SFX.VICTORY_SHORT : SFX.CASINO_LOSE); + this.showTurnBanner(); + + api.post('/history/single-player', { + slug: 'jewelquest', + score: p.stats.damageDealt, + opponentScores: [this.match.players[1].stats.damageDealt], + result: won ? 'win' : 'loss', + }).catch(() => {}); + + this.milestoneMsg = null; + if (won) { + const before = this.loadout(); + if (this.level > this.levelsCompleted) this.levelsCompleted = this.level; + const after = this.loadout(); + const msgs = []; + if (after.spellCount > before.spellCount) { + const cls = CLASSES[this.playerClass]; + for (let s = before.spellCount; s < after.spellCount; s++) { + msgs.push(`New spell unlocked: ${SPELLS[cls.spells[s]].name}!`); + } + } + if (after.maxHp > before.maxHp) msgs.push(`Max HP increased to ${after.maxHp}!`); + if (msgs.length) this.milestoneMsg = msgs.join('\n'); + + api.post('/puzzles/jewelquest/complete', { level: this.level }) + .then((res) => { + if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); + }) + .catch(() => {}); + } + + this.time.delayedCall(900, () => this.showEndModal(won)); + } + + showEndModal(won) { + if (this.view !== 'battle' || !this.match) return; // user already left the battle + this.overlayUp = true; + const cx = GAME_WIDTH / 2; + const cy = GAME_HEIGHT / 2; + const tall = this.milestoneMsg ? 480 : 420; + 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 - 340, cy - tall / 2, 680, tall, 20); + panel.lineStyle(3, won ? COLORS.accent : COLORS.danger, 1); + panel.strokeRoundedRect(cx - 340, cy - tall / 2, 680, tall, 20); + this.layer.add([dim, panel]); + + const p = this.match.players[0]; + const title = this.add.text(cx, cy - tall / 2 + 70, won ? 'VICTORY!' : 'DEFEATED', { + fontFamily: 'Righteous', fontSize: '64px', color: won ? COLORS.goldHex : COLORS.dangerHex, + }).setOrigin(0.5).setDepth(D.overlayUI); + const stat = this.add.text(cx, cy - tall / 2 + 150, + won + ? `You beat ${this.opponent.name}!\nDamage dealt: ${p.stats.damageDealt} Best cascade: ${p.stats.bestCascade}` + : `${this.opponent.name} wore you down.\nDamage dealt: ${p.stats.damageDealt} Best cascade: ${p.stats.bestCascade}`, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add([title, stat]); + + let by = cy - tall / 2 + 230; + if (this.milestoneMsg) { + const ms = this.add.text(cx, by, this.milestoneMsg, { + fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex, align: 'center', lineSpacing: 6, + }).setOrigin(0.5).setDepth(D.overlayUI); + this.layer.add(ms); + by += 70; + } + + const btns = []; + if (won) { + const hasNext = this.level < this.bank.length; + if (hasNext) { + btns.push(new Button(this, cx, by + 20, `Next Fight (${this.level + 1})`, () => this.showIntro(this.level + 1), + { width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI)); + } else { + btns.push(this.add.text(cx, by + 15, 'You beat every rival. Champion of the Jewel Quest!', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlayUI)); + } + btns.push(new Button(this, cx - 110, by + 100, 'Rematch', () => this.startBattle(this.level), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI)); + btns.push(new Button(this, cx + 120, by + 100, 'Levels', () => this.showLevelSelect(), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI)); + } else { + btns.push(new Button(this, cx - 110, by + 50, 'Retry', () => this.startBattle(this.level), + { width: 200, height: 60, fontSize: 26 }).setDepth(D.overlayUI)); + btns.push(new Button(this, cx + 120, by + 50, 'Levels', () => this.showLevelSelect(), + { width: 200, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI)); + } + this.layer.add(btns); + } +} diff --git a/public/src/games/jewelquest/JewelQuestLogic.js b/public/src/games/jewelquest/JewelQuestLogic.js new file mode 100644 index 0000000..0ed072c --- /dev/null +++ b/public/src/games/jewelquest/JewelQuestLogic.js @@ -0,0 +1,675 @@ +// Jewel Quest — pure game engine (no Phaser, no DOM, no timers). +// A Puzzle Quest style match-3 battle: two combatants share one 8x8 board and +// alternate turns; a turn is either a gem swap or a spell cast. Matched mana +// gems fill the actor's pools, matched skulls damage the opponent, any 4+ run +// grants an extra turn. The scene (or a headless script) drives all timing; +// every action returns an ordered event list, each event carrying board and +// meter snapshots, which the renderer replays as animation. +// +// Determinism contract: one shared match.rng (mulberry32) drives board +// generation, refills, reshuffles, and all spell randomness. Same seed + same +// action sequence => byte-identical event streams (pinned in +// verifyJewelQuest.js). AI simulations run on cloneMatch() with a fresh rng so +// they never consume — or peek at — the real match's stream. + +import { CLASSES, SPELLS } from './JewelQuestData.js'; + +export const SIZE = 8; +export const MANA_COLORS = ['red', 'green', 'blue', 'yellow']; +export const MANA_CAP = 25; +export const SKULL5_BONUS = 5; // a skull5 deals 1 + SKULL5_BONUS when matched + +// Spawn weights, overridable per ladder level via jewelquest.json "weights". +export const DEFAULT_WEIGHTS = { + red: 21.5, green: 21.5, blue: 21.5, yellow: 21.5, + skull: 10, skull5: 1.5, wild: 1.5, +}; +const TYPE_ORDER = ['red', 'green', 'blue', 'yellow', 'skull', 'skull5', 'wild']; + +// Run classes: the four mana colors plus 'skull' (skull + skull5 match +// together). Wilds are jokers for any mana color but never bridge skulls. +const RUN_CLASSES = [...MANA_COLORS, 'skull']; + +// ── Seeded RNG (mulberry32, matches genRushHour.js) ───────────────────────── +export function makeRng(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// ── Cells ──────────────────────────────────────────────────────────────────── +function randomGem(rng, weights) { + let total = 0; + for (const t of TYPE_ORDER) total += weights[t] || 0; + let roll = rng() * total; + for (const t of TYPE_ORDER) { + roll -= weights[t] || 0; + if (roll < 0) { + return t === 'wild' ? { type: 'wild', mult: rng() < 0.75 ? 2 : 3 } : { type: t }; + } + } + return { type: 'red' }; // unreachable +} + +export function isMana(cell) { return !!cell && MANA_COLORS.includes(cell.type); } + +function matchesClass(cell, cls) { + if (!cell) return false; + if (cls === 'skull') return cell.type === 'skull' || cell.type === 'skull5'; + return cell.type === cls || cell.type === 'wild'; +} + +function classesOf(cell) { + if (cell.type === 'skull' || cell.type === 'skull5') return ['skull']; + if (cell.type === 'wild') return MANA_COLORS; + return [cell.type]; +} + +function cloneCell(cell) { return cell ? { ...cell } : null; } +function cloneBoard(board) { return board.map((row) => row.map(cloneCell)); } + +// Fixture helper: build a board from 8 strings of 8 chars. +// R/G/B/Y mana, S skull, F skull5 ("five"), W wild x2, V wild x3, '.' empty. +export function boardFromStrings(rows) { + const map = { R: 'red', G: 'green', B: 'blue', Y: 'yellow', S: 'skull', F: 'skull5' }; + return rows.map((row) => [...row].map((ch) => { + if (ch === '.') return null; + if (ch === 'W') return { type: 'wild', mult: 2 }; + if (ch === 'V') return { type: 'wild', mult: 3 }; + return { type: map[ch] }; + })); +} + +// ── Board generation / reshuffle ───────────────────────────────────────────── +// Would placing `cand` at (r,c) complete a 3-run with the two cells left or +// the two cells above? (Only those directions exist during row-major fill.) +function validTriple(a, b, cand) { + if (!a || !b || !cand) return false; + for (const cls of RUN_CLASSES) { + if (!matchesClass(a, cls) || !matchesClass(b, cls) || !matchesClass(cand, cls)) continue; + if (cls === 'skull') return true; + if ([a, b, cand].some((x) => x.type !== 'wild')) return true; + } + return false; +} + +export function generateBoard(rng, weights) { + let board = null; + for (let attempt = 0; attempt < 50; attempt++) { + board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null)); + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + let cell = randomGem(rng, weights); + for (let tries = 0; tries < 60; tries++) { + const h = c >= 2 && validTriple(board[r][c - 2], board[r][c - 1], cell); + const v = r >= 2 && validTriple(board[r - 2][c], board[r - 1][c], cell); + if (!h && !v) break; + cell = randomGem(rng, weights); + } + board[r][c] = cell; + } + } + if (legalSwaps(board).length) return board; + } + return board; // statistically unreachable; verify pins generation quality +} + +// ── Match construction ─────────────────────────────────────────────────────── +function createPlayer(classId, hp, spellCount) { + const count = Math.max(1, Math.min(5, spellCount)); + return { + classId, + hp, + maxHp: hp, + mana: { red: 0, green: 0, blue: 0, yellow: 0 }, + spells: CLASSES[classId].spells.slice(0, count), + status: { skullBuff: 0, stunned: false }, + stats: { damageDealt: 0, manaGained: 0, bestCascade: 0, spellsCast: 0 }, + }; +} + +export function createMatch({ + seed = 1, + classes = ['knight', 'knight'], + hp = [50, 50], + spellCounts = [5, 5], + weights = null, +} = {}) { + const rng = makeRng(seed); + const w = { ...DEFAULT_WEIGHTS, ...(weights || {}) }; + return { + rng, + weights: w, + board: generateBoard(rng, w), + players: [ + createPlayer(classes[0], hp[0], spellCounts[0]), + createPlayer(classes[1], hp[1], spellCounts[1]), + ], + turn: 0, + over: false, + winner: null, + headless: false, + }; +} + +// ── Snapshots (for renderer playback) ──────────────────────────────────────── +function meterSnap(p) { + return { hp: p.hp, maxHp: p.maxHp, mana: { ...p.mana }, status: { ...p.status } }; +} + +function evt(match, type, extra = {}) { + return { + type, + ...extra, + board: cloneBoard(match.board), + players: match.players.map(meterSnap), + turn: match.turn, + }; +} + +// ── Run / match detection ──────────────────────────────────────────────────── +// Returns merged groups: { cls, keys:Set, maxRun }. A run is a +// maximal line of 3+ cells matching one class; mana runs need >=1 non-wild. +export function findRuns(board) { + const runs = []; + const scanLine = (cls, cellAt, len, fixed) => { + let i = 0; + while (i < len) { + if (!matchesClass(cellAt(i), cls)) { i++; continue; } + let end = i; + let nonWild = 0; + while (end < len && matchesClass(cellAt(end), cls)) { + if (cellAt(end).type !== 'wild') nonWild++; + end++; + } + if (end - i >= 3 && (cls === 'skull' || nonWild > 0)) { + runs.push({ cls, len: end - i, fixed, from: i }); + } + i = end; + } + }; + for (const cls of RUN_CLASSES) { + for (let r = 0; r < SIZE; r++) scanLine(cls, (c) => board[r][c], SIZE, { row: r }); + for (let c = 0; c < SIZE; c++) scanLine(cls, (r) => board[r][c], SIZE, { col: c }); + } + + const groups = []; + for (const run of runs) { + const keys = new Set(); + for (let i = run.from; i < run.from + run.len; i++) { + keys.add(run.fixed.row != null ? run.fixed.row * SIZE + i : i * SIZE + run.fixed.col); + } + groups.push({ cls: run.cls, keys, maxRun: run.len }); + } + // Merge same-class groups sharing any cell (L/T shapes), to a fixed point. + let merged = true; + while (merged) { + merged = false; + outer: for (let i = 0; i < groups.length; i++) { + for (let j = i + 1; j < groups.length; j++) { + if (groups[i].cls !== groups[j].cls) continue; + let overlap = false; + for (const k of groups[j].keys) if (groups[i].keys.has(k)) { overlap = true; break; } + if (overlap) { + for (const k of groups[j].keys) groups[i].keys.add(k); + groups[i].maxRun = Math.max(groups[i].maxRun, groups[j].maxRun); + groups.splice(j, 1); + merged = true; + break outer; + } + } + } + } + return groups; +} + +// Longest valid run through (r,c) in either axis, for any class the cell can +// serve. Used to validate swaps without scanning the whole board. +function makesRunAt(board, r, c) { + const cell = board[r][c]; + if (!cell) return false; + for (const cls of classesOf(cell)) { + for (const [dr, dc] of [[0, 1], [1, 0]]) { + let len = 1; + let nonWild = cell.type !== 'wild' ? 1 : 0; + for (const dir of [-1, 1]) { + let rr = r + dr * dir, cc = c + dc * dir; + while (rr >= 0 && rr < SIZE && cc >= 0 && cc < SIZE && matchesClass(board[rr][cc], cls)) { + if (board[rr][cc].type !== 'wild') nonWild++; + len++; + rr += dr * dir; cc += dc * dir; + } + } + if (len >= 3 && (cls === 'skull' || nonWild > 0)) return true; + } + } + return false; +} + +function doSwap(board, a, b) { + const tmp = board[a.r][a.c]; + board[a.r][a.c] = board[b.r][b.c]; + board[b.r][b.c] = tmp; +} + +export function swapCreatesMatch(board, a, b) { + doSwap(board, a, b); + const ok = makesRunAt(board, a.r, a.c) || makesRunAt(board, b.r, b.c); + doSwap(board, a, b); + return ok; +} + +// Runs that would exist after a hypothetical swap, without gravity/refill. +// Cheap one-ply threat estimation for the AI (e.g. "what's the opponent's +// best skull match on this board?"). +export function previewSwapRuns(board, a, b) { + doSwap(board, a, b); + const groups = findRuns(board); + doSwap(board, a, b); + return groups; +} + +export function legalSwaps(board) { + const swaps = []; + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + if (c + 1 < SIZE && swapCreatesMatch(board, { r, c }, { r, c: c + 1 })) { + swaps.push({ a: { r, c }, b: { r, c: c + 1 } }); + } + if (r + 1 < SIZE && swapCreatesMatch(board, { r, c }, { r: r + 1, c })) { + swaps.push({ a: { r, c }, b: { r: r + 1, c } }); + } + } + } + return swaps; +} + +// ── Gravity + refill ───────────────────────────────────────────────────────── +function collapse(board) { + const moves = []; + for (let c = 0; c < SIZE; c++) { + let write = SIZE - 1; + for (let r = SIZE - 1; r >= 0; r--) { + const cell = board[r][c]; + if (!cell) continue; + if (write !== r) { + board[write][c] = cell; + board[r][c] = null; + moves.push({ c, fromR: r, toR: write }); + } + write--; + } + } + return moves; +} + +// Fixed refill order — column 0..7, bottom-most gap upward — is part of the +// determinism contract. +function refill(match) { + const cells = []; + for (let c = 0; c < SIZE; c++) { + for (let r = SIZE - 1; r >= 0; r--) { + if (!match.board[r][c]) { + const cell = randomGem(match.rng, match.weights); + match.board[r][c] = cell; + cells.push({ r, c, cell: { ...cell } }); + } + } + } + return cells; +} + +// ── Resolution pipeline ────────────────────────────────────────────────────── +// Clears resting runs, applies mana/damage to the actor, cascades through +// gravity + refill until the board rests. All gains credit `actorIdx`. +function resolveBoard(match, actorIdx, push) { + const actor = match.players[actorIdx]; + const defender = match.players[1 - actorIdx]; + let extraTurn = false; + let cascade = 0; + + while (!match.over) { + const groups = findRuns(match.board); + if (!groups.length) break; + cascade += 1; + + let stepDamage = 0; + let skullsCleared = 0; + const manaGained = { red: 0, green: 0, blue: 0, yellow: 0 }; + const groupInfo = []; + + for (const g of groups) { + const cells = [...g.keys].map((k) => { + const r = Math.floor(k / SIZE), c = k % SIZE; + return { r, c, ...match.board[r][c] }; + }); + if (g.maxRun >= 4) extraTurn = true; + if (g.cls === 'skull') { + let dmg = 0; + for (const cell of cells) dmg += cell.type === 'skull5' ? 1 + SKULL5_BONUS : 1; + skullsCleared += cells.length; + stepDamage += dmg; + groupInfo.push({ cls: 'skull', cells, damage: dmg, maxRun: g.maxRun }); + } else { + let base = 0, mult = 1; + for (const cell of cells) { + if (cell.type === 'wild') mult *= cell.mult; + else base += 1; + } + const gained = base * mult; + manaGained[g.cls] += gained; + groupInfo.push({ cls: g.cls, cells, mana: gained, maxRun: g.maxRun }); + } + } + // Skull buff applies once per clear step in which any skulls matched. + if (skullsCleared > 0 && actor.status.skullBuff > 0) stepDamage += actor.status.skullBuff; + + for (const color of MANA_COLORS) { + const add = Math.min(manaGained[color], MANA_CAP - actor.mana[color]); + actor.mana[color] += Math.max(0, add); + actor.stats.manaGained += Math.max(0, add); + } + if (stepDamage > 0) { + defender.hp = Math.max(0, defender.hp - stepDamage); + actor.stats.damageDealt += stepDamage; + if (defender.hp <= 0) { match.over = true; match.winner = actorIdx; } + } + actor.stats.bestCascade = Math.max(actor.stats.bestCascade, cascade); + + for (const g of groups) { + for (const k of g.keys) match.board[Math.floor(k / SIZE)][k % SIZE] = null; + } + push('clear', { groups: groupInfo, damage: stepDamage, manaGained, cascade, actor: actorIdx }); + + if (match.over) break; + const moves = collapse(match.board); + if (moves.length) push('fall', { moves }); + const filled = refill(match); + if (filled.length) push('refill', { cells: filled }); + } + return { extraTurn, cascades: cascade }; +} + +// Turn bookkeeping shared by swaps and spell casts. Guarantees the board has a +// legal move before control returns (reshuffling if needed). +function finishAction(match, extraTurn, push) { + if (match.over) { + push('gameOver', { winner: match.winner }); + return; + } + if (extraTurn) { + push('extraTurn', {}); + } else { + match.turn = 1 - match.turn; + const next = match.players[match.turn]; + if (next.status.stunned) { + next.status.stunned = false; + push('skipTurn', { skipped: match.turn }); + match.turn = 1 - match.turn; + } + } + // AI lookahead clones (match.sim) skip the move guard — the cost isn't + // worth it for boards that are immediately discarded. + if (!match.sim) { + let guard = 0; + while (!legalSwaps(match.board).length && guard++ < 20) { + match.board = generateBoard(match.rng, match.weights); + push('shuffle', {}); + } + } + push('turnEnd', {}); +} + +// ── Actions ────────────────────────────────────────────────────────────────── +export function applySwap(match, a, b) { + if (match.over) return { legal: false, events: [] }; + if (Math.abs(a.r - b.r) + Math.abs(a.c - b.c) !== 1) return { legal: false, events: [] }; + if (!match.board[a.r]?.[a.c] || !match.board[b.r]?.[b.c]) return { legal: false, events: [] }; + if (!swapCreatesMatch(match.board, a, b)) return { legal: false, events: [] }; + + const events = []; + const push = (type, extra) => { if (!match.headless) events.push(evt(match, type, extra)); }; + + doSwap(match.board, a, b); + push('swap', { a, b }); + const { extraTurn } = resolveBoard(match, match.turn, push); + finishAction(match, extraTurn, push); + return { legal: true, events }; +} + +export function canAfford(player, spellId) { + const spell = SPELLS[spellId]; + if (!spell) return false; + return Object.entries(spell.cost).every(([color, amt]) => player.mana[color] >= amt); +} + +function pickRandom(rng, items, count) { + const pool = [...items]; + const picked = []; + while (pool.length && picked.length < count) { + picked.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]); + } + return picked; +} + +function allCells(board, pred = () => true) { + const out = []; + for (let r = 0; r < SIZE; r++) { + for (let c = 0; c < SIZE; c++) { + if (board[r][c] && pred(board[r][c])) out.push({ r, c }); + } + } + return out; +} + +// Applies one spell effect. Returns true if the board was altered. +function applyEffect(match, fx, casterIdx, push) { + const caster = match.players[casterIdx]; + const target = match.players[1 - casterIdx]; + const board = match.board; + + switch (fx.kind) { + case 'damage': { + target.hp = Math.max(0, target.hp - fx.amount); + caster.stats.damageDealt += fx.amount; + if (target.hp <= 0) { match.over = true; match.winner = casterIdx; } + push('damage', { amount: fx.amount, target: 1 - casterIdx }); + return false; + } + case 'heal': { + caster.hp = Math.min(caster.maxHp, caster.hp + fx.amount); + push('heal', { amount: fx.amount, target: casterIdx }); + return false; + } + case 'drainMana': + case 'stealMana': { + let color = fx.color; + if (color === 'largest') { + color = MANA_COLORS.reduce((best, c) => (target.mana[c] > target.mana[best] ? c : best), 'red'); + } + const amount = Math.min(fx.amount, target.mana[color]); + target.mana[color] -= amount; + if (fx.kind === 'stealMana') { + caster.mana[color] = Math.min(MANA_CAP, caster.mana[color] + amount); + } + push('mana', { kind: fx.kind, color, amount, target: 1 - casterIdx }); + return false; + } + case 'destroyGems': { + const sel = fx.selector; + let cells = []; + if (sel.mode === 'random') { + cells = pickRandom(match.rng, allCells(board), sel.count); + } else if (sel.mode === 'color') { + cells = allCells(board, (cell) => cell.type === sel.color); + } else if (sel.mode === 'skulls') { + cells = allCells(board, (cell) => cell.type === 'skull' || cell.type === 'skull5'); + } else if (sel.mode === 'column') { + const c = Math.floor(match.rng() * SIZE); + for (let r = 0; r < SIZE; r++) if (board[r][c]) cells.push({ r, c }); + } else if (sel.mode === 'row') { + const r = Math.floor(match.rng() * SIZE); + for (let c = 0; c < SIZE; c++) if (board[r][c]) cells.push({ r, c }); + } + let damage = 0; + const destroyed = []; + for (const { r, c } of cells) { + const cell = board[r][c]; + destroyed.push({ r, c, ...cell }); + if (isMana(cell)) { + caster.mana[cell.type] = Math.min(MANA_CAP, caster.mana[cell.type] + 1); + caster.stats.manaGained += 1; + } else if (!sel.harmless && (cell.type === 'skull' || cell.type === 'skull5')) { + damage += cell.type === 'skull5' ? 1 + SKULL5_BONUS : 1; + } + board[r][c] = null; + } + if (damage > 0) { + target.hp = Math.max(0, target.hp - damage); + caster.stats.damageDealt += damage; + if (target.hp <= 0) { match.over = true; match.winner = casterIdx; } + } + push('destroy', { cells: destroyed, damage, harmless: !!sel.harmless }); + return destroyed.length > 0; + } + case 'transformGems': { + let candidates; + if (fx.from === 'random') { + candidates = allCells(board, (cell) => isMana(cell) && cell.type !== fx.to); + } else { + candidates = allCells(board, (cell) => cell.type === fx.from); + } + const cells = fx.count === 'all' + ? candidates + : pickRandom(match.rng, candidates, fx.count); + const changed = []; + for (const { r, c } of cells) { + board[r][c] = { type: fx.to }; + changed.push({ r, c, type: fx.to }); + } + push('transform', { cells: changed, to: fx.to }); + return changed.length > 0; + } + case 'buffSkullDamage': { + caster.status.skullBuff += fx.amount; + push('buff', { amount: fx.amount, target: casterIdx }); + return false; + } + case 'stun': { + target.status.stunned = true; + push('stun', { target: 1 - casterIdx }); + return false; + } + default: + return false; + } +} + +export function castSpell(match, spellId) { + if (match.over) return { legal: false, events: [] }; + const casterIdx = match.turn; + const caster = match.players[casterIdx]; + const spell = SPELLS[spellId]; + if (!spell || !caster.spells.includes(spellId)) return { legal: false, events: [] }; + if (!canAfford(caster, spellId)) return { legal: false, events: [] }; + + const events = []; + const push = (type, extra) => { if (!match.headless) events.push(evt(match, type, extra)); }; + + for (const [color, amt] of Object.entries(spell.cost)) caster.mana[color] -= amt; + caster.stats.spellsCast += 1; + push('spell', { caster: casterIdx, spellId, name: spell.name }); + + let boardChanged = false; + for (const fx of spell.effects) { + if (match.over) break; + boardChanged = applyEffect(match, fx, casterIdx, push) || boardChanged; + } + if (boardChanged && !match.over) { + const moves = collapse(match.board); + if (moves.length) push('fall', { moves }); + const filled = refill(match); + if (filled.length) push('refill', { cells: filled }); + // Cascades triggered by the spell credit the caster (Puzzle Quest rule), + // but spells never grant an extra turn. + resolveBoard(match, casterIdx, push); + } + finishAction(match, false, push); + return { legal: true, events }; +} + +// ── Milestone / loadout helper ─────────────────────────────────────────────── +// Derives the player's max HP and unlocked spell count from ladder progress. +// Pure: scene and verify script share it; unlocks need no extra storage. +export function playerLoadout(config, levelsCompleted) { + let maxHp = config.playerBaseHp ?? 50; + let spellCount = 3; + for (const m of config.milestones ?? []) { + if (levelsCompleted >= m.afterLevel) { + maxHp += m.maxHpBonus ?? 0; + if (m.unlockSpellSlot) spellCount = Math.max(spellCount, m.unlockSpellSlot); + } + } + return { maxHp, spellCount }; +} + +// ── AI support ─────────────────────────────────────────────────────────────── +// Deep clone with a fresh rng: simulations sample refill luck without +// consuming (or revealing) the real match's rng stream. +export function cloneMatch(match, seed = 1) { + return { + rng: makeRng(seed), + weights: match.weights, + board: cloneBoard(match.board), + players: match.players.map((p) => ({ + ...p, + mana: { ...p.mana }, + status: { ...p.status }, + stats: { ...p.stats }, + spells: [...p.spells], + })), + turn: match.turn, + over: match.over, + winner: match.winner, + headless: true, + sim: true, + }; +} + +function actionMetrics(sim, pIdx, before) { + const me = sim.players[pIdx]; + const foe = sim.players[1 - pIdx]; + return { + damage: me.stats.damageDealt - before.damageDealt, + manaGained: me.stats.manaGained - before.manaGained, + healed: Math.max(0, me.hp - before.hp), + foeHp: foe.hp, + keptTurn: !sim.over && sim.turn === pIdx, + won: sim.over && sim.winner === pIdx, + sim, + }; +} + +export function simulateSwap(match, pIdx, a, b, seed = 1) { + const sim = cloneMatch(match, seed); + sim.turn = pIdx; + const me = sim.players[pIdx]; + const before = { damageDealt: me.stats.damageDealt, manaGained: me.stats.manaGained, hp: me.hp }; + const res = applySwap(sim, a, b); + if (!res.legal) return null; + return actionMetrics(sim, pIdx, before); +} + +export function simulateSpell(match, pIdx, spellId, seed = 1) { + const sim = cloneMatch(match, seed); + sim.turn = pIdx; + const me = sim.players[pIdx]; + const before = { damageDealt: me.stats.damageDealt, manaGained: me.stats.manaGained, hp: me.hp }; + const res = castSpell(sim, spellId); + if (!res.legal) return null; + return actionMetrics(sim, pIdx, before); +} diff --git a/public/src/games/jewelquest/tutorial.md b/public/src/games/jewelquest/tutorial.md new file mode 100644 index 0000000..bd9e687 --- /dev/null +++ b/public/src/games/jewelquest/tutorial.md @@ -0,0 +1,50 @@ +# Jewel Quest + +A spellcasting duel fought on a gem board. You and your rival take turns +swapping gems — drain your opponent's hit points to zero to win, then beat +each rival on the ladder to unlock the next. + +## The Board + +- On your turn, **swap two adjacent gems** to line up 3 or more of a kind. + Tap a gem and then a neighbor, or drag a gem toward a neighbor. +- A swap that doesn't make a match simply bounces back — it never wastes + your turn. +- If the board ever has no possible moves, it reshuffles automatically. + +## Mana & Spells + +- Matching **colored gems** (red, green, blue, yellow) fills your mana pools, + up to 25 of each color. +- Spend mana on your class's **spells** — healing, fireballs, stuns, mana + theft, board-warping magic, and more. Casting a spell takes your turn. +- **Wildcard gems** match any color and **multiply** the mana from the run + they complete (×2 or ×3). + +## Skulls + +- Matching **skulls** deals 1 damage each, straight to your opponent. +- The rare **red +5 skull** deals 6 damage when matched. +- Some spells forge extra skulls or make every skull match hit harder. + +## Extra Turns & Cascades + +- Match **4 or more** in a line and you go again. +- When matched gems vanish, everything above falls and new gems rain in. If + the falling gems line up a new match, it's a **cascade** — every cascade + step counts for you, mana and damage alike. + +## Classes & Unlocks + +- Pick one of four champions: the **Knight** (skulls and steel), the + **Sorcerer** (devastating mana bursts), the **Druid** (healing and board + control), or the **Assassin** (stuns and stolen mana). +- You begin with your class's first **3 spells**. Climb the ladder to unlock + the rest — and bonus max HP — at set milestones. You can change class + between battles without losing progress. + +## Winning + +- Reduce your rival's HP to zero before they do the same to you. +- Each victory unlocks the next rival. Twenty await — the deeper you go, the + smarter, tougher, and better-armed they get. diff --git a/public/src/main.js b/public/src/main.js index ab7e976..886ae0e 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -68,6 +68,7 @@ import ShiftGame from './games/shift/ShiftGame.js'; import BlockFighterGame from './games/blockfighter/BlockFighterGame.js'; import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js'; import MahjongGame from './games/mahjong/MahjongGame.js'; +import JewelQuestGame from './games/jewelquest/JewelQuestGame.js'; const config = { type: Phaser.AUTO, @@ -149,6 +150,7 @@ const config = { BlockFighterGame, MahjongMatchGame, MahjongGame, + JewelQuestGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 26af7ff..34e2b38 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' }; + 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' }; 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 1fd3e8b..f84bfcc 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -63,6 +63,7 @@ export default class PreloadScene extends Phaser.Scene { this.load.json('puddingmonsters', '/data/puddingmonsters.json'); this.load.json('shift-artwork', '/data/shift-artwork.json'); this.load.json('blockfighter', '/data/blockfighter.json'); + this.load.json('jewelquest', '/data/jewelquest.json'); this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3'); this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3'); diff --git a/public/src/ui/Portrait.js b/public/src/ui/Portrait.js index 351d018..62bae3c 100644 --- a/public/src/ui/Portrait.js +++ b/public/src/ui/Portrait.js @@ -207,7 +207,11 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, scene.tweens.add({ targets, alpha: 0.2, duration }); } + let destroyed = false; function destroy() { + if (destroyed) return; + destroyed = true; + scene.events.off('shutdown', destroy); videoEl.pause(); videoEl.src = ''; resetSpeechQueue(); @@ -216,6 +220,9 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, clearInterval(retargetTimer); retargetTimer = null; canvasDom.destroy(); + domEl.destroy(); + backingG.destroy(); + if (spriteImg) spriteImg.destroy(); } scene.events.once('shutdown', destroy); @@ -246,13 +253,14 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene }).setOrigin(0.5).setDepth(depth + 1); const allObjs = [backingG, placeholder]; + let destroyed = false; // Async avatar load (async () => { try { const { profile } = await api.get('/profile'); if (!profile?.avatarPath) return; - if (!scene.scene.isActive(sceneName)) return; + if (destroyed || !scene.scene.isActive(sceneName)) return; const key = `player-avatar-${profile.id}`; if (!scene.textures.exists(key)) { @@ -262,7 +270,7 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene scene.load.start(); }); } - if (!scene.scene.isActive(sceneName)) return; + if (destroyed || !scene.scene.isActive(sceneName)) return; const maskG = scene.make.graphics({ x: 0, y: 0, add: false }); maskG.fillStyle(0xffffff); @@ -287,5 +295,12 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene if (targets.length > 0) scene.tweens.add({ targets, alpha: 0.2, duration }); } - return { hide, show, stopVideo, fadeToEliminated, destroy() {} }; + function destroy() { + if (destroyed) return; + destroyed = true; + for (const o of allObjs) { try { o.destroy(); } catch (_) { /* already gone */ } } + allObjs.length = 0; + } + + return { hide, show, stopVideo, fadeToEliminated, destroy }; } diff --git a/server/games/registry.js b/server/games/registry.js index 9f6fc8d..d1888fc 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -83,3 +83,4 @@ registerGame({ slug: 'shift', name: 'Shift', category: ' registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 56 }); registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 57 }); registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 }); +registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 }); diff --git a/server/scripts/verifyJewelQuest.js b/server/scripts/verifyJewelQuest.js new file mode 100644 index 0000000..1558eac --- /dev/null +++ b/server/scripts/verifyJewelQuest.js @@ -0,0 +1,603 @@ +// Headless verification for Jewel Quest. +// node server/scripts/verifyJewelQuest.js [--quick] +// Exits non-zero on any failure. +// +// 1. Fixture tests: exact engine behavior on hand-built boards. +// 2. AI-vs-AI self-play with invariant checks after every action. +// 3. Skill differentiation matrix (higher skill should win more). +// 4. Class balance matrix at skill 5 (info + loose sanity band). +// 5. Ladder lint (public/data/jewelquest.json vs opponents.json). + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { + SIZE, MANA_COLORS, MANA_CAP, + createMatch, applySwap, castSpell, findRuns, legalSwaps, + boardFromStrings, playerLoadout, +} from '../../public/src/games/jewelquest/JewelQuestLogic.js'; +import { CLASSES, SPELLS } from '../../public/src/games/jewelquest/JewelQuestData.js'; +import { createAI, chooseAction } from '../../public/src/games/jewelquest/JewelQuestAI.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const QUICK = process.argv.includes('--quick'); + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); } + else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); } +} + +// ── Fixture helpers ────────────────────────────────────────────────────────── +// Diagonal 4-color tiling: no resting runs and (except for the skull motif in +// the top-right corner) no legal swaps — a controlled canvas for edits. The +// S/S motif guarantees one legal swap so post-action move guards never +// reshuffle a fixture board. +const BASE = [ + 'RGBYRGSS', + 'GBYRGSYR', + 'BYRGBYRG', + 'YRGBYRGB', + 'RGBYRGBY', + 'GBYRGBYR', + 'BYRGBYRG', + 'YRGBYRGB', +]; +// The same tiling without the motif: zero legal swaps anywhere. +const BASE_DEAD = [ + 'RGBYRGBY', + 'GBYRGBYR', + 'BYRGBYRG', + 'YRGBYRGB', + 'RGBYRGBY', + 'GBYRGBYR', + 'BYRGBYRG', + 'YRGBYRGB', +]; + +const cell = (type) => ({ type }); +const wild = (mult = 2) => ({ type: 'wild', mult }); + +function fixtureMatch(rows = BASE, edits = [], opts = {}) { + const m = createMatch({ seed: 5, ...opts }); + m.board = boardFromStrings(rows); + for (const [r, c, x] of edits) m.board[r][c] = typeof x === 'string' ? cell(x) : x; + return m; +} + +function assertClean(name, m) { + check(`${name}: fixture board has no resting runs`, findRuns(m.board).length === 0); +} + +// Values that land inside each type's DEFAULT_WEIGHTS band. +const RNG_VAL = { red: 0.10, green: 0.30, blue: 0.50, yellow: 0.70, skull: 0.90, skull5: 0.965, wild: 0.985 }; +function stubRng(m, seq) { + let i = 0; + m.rng = () => { + const v = seq[i++ % seq.length]; + return typeof v === 'number' ? v : RNG_VAL[v]; + }; +} + +function countCells(board, pred) { + let n = 0; + for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) { + if (board[r][c] && pred(board[r][c])) n++; + } + return n; +} + +function fullBoard(board) { + return countCells(board, () => true) === SIZE * SIZE; +} + +// ── 1. Fixtures ────────────────────────────────────────────────────────────── +console.log('Fixtures:'); +{ + const m = fixtureMatch(); + assertClean('base', m); + check('base motif provides a legal swap', legalSwaps(m.board).length >= 1); + const dead = boardFromStrings(BASE_DEAD); + check('dead tiling has zero legal swaps', legalSwaps(dead).length === 0); +} +{ + // Swap legality. + const m = fixtureMatch(); + const before = JSON.stringify(m.board); + let res = applySwap(m, { r: 0, c: 0 }, { r: 5, c: 5 }); + check('non-adjacent swap rejected', !res.legal && res.events.length === 0); + res = applySwap(m, { r: 7, c: 0 }, { r: 7, c: 1 }); + check('no-match swap rejected', !res.legal); + check('rejected swap leaves board unchanged', JSON.stringify(m.board) === before); + check('rejected swap leaves turn unchanged', m.turn === 0); +} +{ + // Horizontal 3-match credits the mover. + const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]); + assertClean('3-match', m); + stubRng(m, ['yellow', 'blue']); + const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('3-match swap is legal', res.legal); + check('mover gains 3 red mana', m.players[0].mana.red === 3, `got ${m.players[0].mana.red}`); + check('3-match deals no damage', m.players[1].hp === 50); + check('turn passes after a 3-match', m.turn === 1); + check('board refilled to 64 cells', fullBoard(m.board)); + check('board rests with no runs', findRuns(m.board).length === 0); + check('no shuffle needed', !res.events.some((e) => e.type === 'shuffle')); +} +{ + // Player 1's swaps credit player 1. + const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]); + m.turn = 1; + stubRng(m, ['yellow', 'blue']); + applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('player 1 credited as mover', m.players[1].mana.red === 3 && m.players[0].mana.red === 0); + check('turn returns to player 0', m.turn === 0); +} +{ + // 4-run grants an extra turn. + const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]); + m.board[6][2] = cell('red'); // BASE already has red at (6,2); explicit for clarity + assertClean('4-run', m); + stubRng(m, ['yellow', 'blue']); + const res = applySwap(m, { r: 6, c: 2 }, { r: 7, c: 2 }); + check('4-run swap is legal', res.legal); + check('mover gains 4 red mana', m.players[0].mana.red === 4, `got ${m.players[0].mana.red}`); + check('4-run keeps the turn', m.turn === 0); + check('extraTurn event emitted', res.events.some((e) => e.type === 'extraTurn')); +} +{ + // L-shape merges into one group: 5 cells, counted once, no extra turn. + const m = fixtureMatch(BASE, [[7, 2, 'red'], [7, 4, 'red'], [6, 3, 'red']]); + assertClean('L-merge', m); + stubRng(m, ['yellow', 'blue']); + const res = applySwap(m, { r: 7, c: 3 }, { r: 7, c: 4 }); + check('L-merge swap is legal', res.legal); + check('L-shape yields exactly 5 red mana', m.players[0].mana.red === 5, `got ${m.players[0].mana.red}`); + const clears = res.events.filter((e) => e.type === 'clear'); + check('L-shape clears as one group', clears.length === 1 && clears[0].groups.length === 1); + check('two 3-runs do not grant an extra turn', m.turn === 1); +} +{ + // Wild multiplier: R R W(x2) = 2 base x 2 = 4 mana. + const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, wild(2)]]); + assertClean('wild', m); + stubRng(m, ['yellow', 'blue']); + const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('wild swap is legal', res.legal); + check('wild x2 doubles run mana (2 reds -> 4)', m.players[0].mana.red === 4, `got ${m.players[0].mana.red}`); +} +{ + // Wilds never bridge skulls. + const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, wild(2)]]); + check('S S W is not a skull run', findRuns(m.board).length === 0); +} +{ + // Skull damage: skull=1, skull5=6. + const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, 'green'], [7, 3, 'skull5']]); + assertClean('skull5', m); + stubRng(m, ['yellow', 'blue']); + const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('skull5 swap is legal', res.legal); + check('S+S+F deals 8 damage', m.players[1].hp === 42, `hp ${m.players[1].hp}`); + check('damage recorded in stats', m.players[0].stats.damageDealt === 8); +} +{ + // Skull buff adds once per clear step. + const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, 'green'], [7, 3, 'skull']]); + m.players[0].status.skullBuff = 2; + stubRng(m, ['yellow', 'blue']); + applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('skull buff adds +2 to a skull step', m.players[1].hp === 45, `hp ${m.players[1].hp}`); +} +{ + // Mana caps at MANA_CAP. + const m = fixtureMatch(BASE, [[7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red']]); + m.players[0].mana.red = 24; + stubRng(m, ['yellow', 'blue']); + applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('mana caps at 25', m.players[0].mana.red === MANA_CAP, `got ${m.players[0].mana.red}`); +} +{ + // Two-step cascade: both steps credit the mover. + const m = fixtureMatch(BASE, [ + [7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red'], + [6, 1, 'green'], [6, 2, 'green'], [6, 3, 'blue'], + ]); + assertClean('cascade', m); + stubRng(m, ['yellow', 'blue']); + const res = applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('cascade swap is legal', res.legal); + check('cascade step 1 credits 3 red', m.players[0].mana.red === 3, `got ${m.players[0].mana.red}`); + check('cascade step 2 credits 3 green', m.players[0].mana.green === 3, `got ${m.players[0].mana.green}`); + check('bestCascade recorded', m.players[0].stats.bestCascade === 2); + check('3-run cascade still passes turn', m.turn === 1); + const clears = res.events.filter((e) => e.type === 'clear'); + check('two clear events with cascade index', clears.length === 2 && clears[1].cascade === 2); +} +{ + // A 4-run formed BY a cascade also grants the extra turn. + const m = fixtureMatch(BASE, [ + [7, 0, 'red'], [7, 1, 'red'], [7, 2, 'green'], [7, 3, 'red'], [7, 4, 'green'], + [6, 1, 'green'], [6, 2, 'green'], [6, 3, 'blue'], + ]); + assertClean('cascade-extra', m); + stubRng(m, ['yellow', 'blue']); + applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('cascade 4-run grants extra turn', m.turn === 0); + check('cascade 4-run credits 4 green', m.players[0].mana.green === 4, `got ${m.players[0].mana.green}`); +} +{ + // Refill determinism: same seed + same action => identical event streams. + const run = () => { + const m = createMatch({ seed: 123 }); + const swap = legalSwaps(m.board)[0]; + const res = applySwap(m, swap.a, swap.b); + return JSON.stringify(res.events); + }; + check('same seed produces identical event streams', run() === run()); +} +{ + // Board generation: clean and playable across many seeds. + const N = QUICK ? 80 : 300; + let ok = true; + for (let seed = 1; seed <= N; seed++) { + const m = createMatch({ seed }); + if (!fullBoard(m.board) || findRuns(m.board).length || !legalSwaps(m.board).length) { ok = false; break; } + } + check(`board gen clean + playable over ${N} seeds`, ok); +} + +console.log('Spell fixtures:'); +{ + // Damage spell: cost deducted, hp reduced, turn passes. + const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] }); + m.players[0].mana.red = 4; + const res = castSpell(m, 'shieldBash'); + check('shieldBash legal', res.legal); + check('shieldBash deals 3', m.players[1].hp === 47); + check('mana deducted', m.players[0].mana.red === 0); + check('casting ends the turn', m.turn === 1); + check('spellsCast recorded', m.players[0].stats.spellsCast === 1); +} +{ + // Unaffordable cast rejected without deduction. + const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'] }); + m.players[0].mana.red = 3; + const res = castSpell(m, 'shieldBash'); + check('unaffordable cast rejected', !res.legal && res.events.length === 0); + check('no mana deducted on rejection', m.players[0].mana.red === 3); + check('turn unchanged on rejection', m.turn === 0); +} +{ + // Locked spell slots can't be cast. + const m = fixtureMatch(BASE, [], { classes: ['knight', 'druid'], spellCounts: [3, 5] }); + m.players[0].mana.red = 25; + const res = castSpell(m, 'skullForge'); + check('locked spell slot rejected', !res.legal); +} +{ + // Heal clamps at maxHp. + const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] }); + m.players[0].hp = 47; + m.players[0].mana.green = 5; + castSpell(m, 'regrowth'); + check('heal clamps at maxHp', m.players[0].hp === 50); +} +{ + // Drain hits the opponent's largest pool. + const m = fixtureMatch(BASE, [], { classes: ['assassin', 'knight'] }); + m.players[0].mana.green = 4; + m.players[1].mana.blue = 7; + m.players[1].mana.red = 2; + castSpell(m, 'poisonDart'); + check('poisonDart deals 3', m.players[1].hp === 47); + check('poisonDart drains largest pool', m.players[1].mana.blue === 4); +} +{ + // Steal caps the caster's pool; victim still loses the full amount. + const m = fixtureMatch(BASE, [], { classes: ['assassin', 'knight'] }); + m.players[0].mana.blue = 5; + m.players[0].mana.red = 23; + m.players[1].mana.red = 9; + castSpell(m, 'pickpocket'); + check('steal removes from victim', m.players[1].mana.red === 3); + check('stolen mana caps at 25', m.players[0].mana.red === MANA_CAP); +} +{ + // Column destruction credits all mana to the caster. + const m = fixtureMatch(BASE, [], { classes: ['sorcerer', 'knight'] }); + m.players[0].mana.blue = 7; + stubRng(m, [0.05, 'yellow', 'blue']); // column 0, then refill colors + const res = castSpell(m, 'arcaneFunnel'); + check('arcaneFunnel legal', res.legal); + const destroy = res.events.find((e) => e.type === 'destroy'); + check('column destroy removes 8 cells', destroy && destroy.cells.length === 8); + const p = m.players[0].mana; + check('caster collects column mana (+2 each color)', + p.red === 2 && p.green === 2 && p.yellow === 2 && p.blue === 2, + JSON.stringify(p)); + check('board refilled after column destroy', fullBoard(m.board)); +} +{ + // Stun: opponent's next turn is skipped. + const m = fixtureMatch(BASE, [], { classes: ['druid', 'knight'] }); + m.players[0].mana.green = 9; + const res = castSpell(m, 'entangle'); + check('stun returns the turn to the caster', m.turn === 0); + check('stun flag cleared after the skip', m.players[1].status.stunned === false); + check('skipTurn event emitted', res.events.some((e) => e.type === 'skipTurn')); +} +{ + // Transform-all + spell-triggered cascade credits caster, never grants + // an extra turn. base3 is a 3-color tiling with no yellow; the two added + // yellows transmute to blue and complete a vertical 4-run in column 0. + const base3 = [ + 'RGBRGBRG', + 'BRGBRGBR', + 'GBRGBRGB', + 'RGBRGBRG', + 'BRGBRGBR', + 'GBRGBRGB', + 'RGBRGBRG', + 'BRGBRGBR', + ]; + const m = fixtureMatch(base3, [[2, 0, 'yellow'], [3, 0, 'yellow']], { classes: ['sorcerer', 'knight'] }); + assertClean('transmute', m); + m.players[0].mana.blue = 10; + stubRng(m, ['yellow', 'blue']); + const res = castSpell(m, 'transmute'); + check('transmute legal', res.legal); + const tf = res.events.find((e) => e.type === 'transform'); + check('transmute converts exactly the 2 yellows', tf && tf.cells.length === 2); + check('transform event board has no yellow', tf && countCells(tf.board, (x) => x.type === 'yellow') === 0); + check('spell cascade credits caster 4 blue', m.players[0].mana.blue === 4, `got ${m.players[0].mana.blue}`); + check('spell 4-run does NOT grant extra turn', m.turn === 1); +} +{ + // Harmless skull destruction deals no damage. + const base3 = [ + 'RGBRGBRG', + 'BRGBRGBR', + 'GBRGBRGB', + 'RGBRGBRG', + 'BRGBRGBR', + 'GBRGBRGB', + 'RGBRGBRG', + 'BRGBRGBR', + ]; + const m = fixtureMatch(base3, [[0, 0, 'skull'], [4, 4, 'skull'], [2, 6, 'skull']], { classes: ['druid', 'knight'] }); + assertClean('naturesBalance', m); + m.players[0].hp = 30; + m.players[0].mana.green = 12; + m.players[0].mana.yellow = 8; + stubRng(m, ['yellow', 'blue']); + const res = castSpell(m, 'naturesBalance'); + check('naturesBalance legal', res.legal); + const destroy = res.events.find((e) => e.type === 'destroy'); + check('all skulls destroyed', destroy && destroy.cells.length === 3 && countCells(m.board, (x) => x.type === 'skull' || x.type === 'skull5') === 0); + check('harmless destroy deals 0 damage', destroy.damage === 0 && m.players[1].hp === 50); + check('heal applied first', m.players[0].hp === 40); +} +{ + // Random destruction: count + direct damage land. + const base3 = [ + 'RGBRGBRG', + 'BRGBRGBR', + 'GBRGBRGB', + 'RGBRGBRG', + 'BRGBRGBR', + 'GBRGBRGB', + 'RGBRGBRG', + 'BRGBRGBR', + ]; + const m = fixtureMatch(base3, [], { classes: ['sorcerer', 'knight'] }); + m.players[0].mana.red = 14; + m.players[0].mana.blue = 10; + const res = castSpell(m, 'meteorStorm'); + check('meteorStorm legal', res.legal); + const destroy = res.events.find((e) => e.type === 'destroy'); + check('meteorStorm destroys 8 cells', destroy && destroy.cells.length === 8); + check('meteorStorm direct damage lands', m.players[1].hp <= 44); + check('meteorStorm mana credited to caster', m.players[0].stats.manaGained >= 8); + check('board valid after meteorStorm', fullBoard(m.board) && findRuns(m.board).length === 0); +} +{ + // buffSkullDamage persists and boosts later skull matches. + const m = fixtureMatch(BASE, [[7, 0, 'skull'], [7, 1, 'skull'], [7, 2, 'green'], [7, 3, 'skull']], + { classes: ['knight', 'druid'] }); + m.players[0].mana.red = 12; + m.players[0].mana.yellow = 8; + castSpell(m, 'crusadersWrath'); + check('crusadersWrath direct damage', m.players[1].hp === 42); + check('skullBuff applied', m.players[0].status.skullBuff === 2); + m.turn = 0; // hand the turn back to test the buffed match + stubRng(m, ['yellow', 'blue']); + applySwap(m, { r: 7, c: 2 }, { r: 7, c: 3 }); + check('buffed skull match deals 3+2', m.players[1].hp === 37, `hp ${m.players[1].hp}`); +} +{ + // No-moves board reshuffles after an action. + const m = fixtureMatch(BASE_DEAD, [], { classes: ['knight', 'druid'] }); + m.players[0].mana.red = 4; + const res = castSpell(m, 'shieldBash'); + check('dead board triggers reshuffle', res.events.some((e) => e.type === 'shuffle')); + check('reshuffled board is playable', legalSwaps(m.board).length >= 1 && findRuns(m.board).length === 0); +} +{ + // playerLoadout milestone math against the shipped config. + const config = JSON.parse(readFileSync(join(__dirname, '../../public/data/jewelquest.json'), 'utf8')); + const cases = [ + [0, 50, 3], [4, 50, 3], [5, 55, 4], [10, 60, 5], [15, 70, 5], [20, 70, 5], + ]; + let ok = true; + for (const [done, hp, spells] of cases) { + const lo = playerLoadout(config, done); + if (lo.maxHp !== hp || lo.spellCount !== spells) { ok = false; check(`playerLoadout(${done})`, false, JSON.stringify(lo)); } + } + if (ok) check('playerLoadout milestone math', true); +} + +// ── 2. Self-play invariants ────────────────────────────────────────────────── +function checkInvariants(match) { + // The engine stops resolving the instant someone dies, so the board may + // legitimately hold mid-cascade holes once the match is over. + if (match.over) { + return match.players[1 - match.winner].hp === 0 ? null : 'game over but loser hp != 0'; + } + for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) { + if (!match.board[r][c]) return `hole at ${r},${c}`; + } + if (findRuns(match.board).length) return 'resting runs on board'; + for (const p of match.players) { + for (const color of MANA_COLORS) { + if (p.mana[color] < 0 || p.mana[color] > MANA_CAP) return `mana out of range: ${color}=${p.mana[color]}`; + } + if (p.hp < 0 || p.hp > p.maxHp) return `hp out of range: ${p.hp}`; + } + if (!legalSwaps(match.board).length) return 'no legal moves left'; + return null; +} + +function playGame({ skills, classes, seed, hp = [50, 50], invariants = false }) { + const match = createMatch({ seed, classes, hp, spellCounts: [5, 5] }); + match.headless = true; + const ais = [ + createAI({ skill: skills[0], seed: seed * 7 + 1 }), + createAI({ skill: skills[1], seed: seed * 13 + 5 }), + ]; + let turns = 0; + const MAX_TURNS = 300; + while (!match.over && turns < MAX_TURNS) { + const pIdx = match.turn; + const action = chooseAction(ais[pIdx], match, pIdx); + if (!action) return { error: 'AI returned no action', turns }; + const res = action.type === 'spell' + ? castSpell(match, action.spellId) + : applySwap(match, action.a, action.b); + if (!res.legal) return { error: `AI chose illegal ${action.type}`, turns }; + turns++; + if (invariants) { + const err = checkInvariants(match); + if (err) return { error: err, turns }; + } + } + return { winner: match.over ? match.winner : null, turns }; +} + +console.log('Self-play invariants:'); +{ + const N = QUICK ? 8 : 30; + const classIds = Object.keys(CLASSES); + let bad = null; + let finished = 0; + let totalTurns = 0; + for (let g = 0; g < N && !bad; g++) { + const classes = [classIds[g % 4], classIds[(g + g % 3 + 1) % 4]]; + const res = playGame({ skills: [5, 5], classes, seed: 1000 + g, invariants: true }); + if (res.error) bad = `game ${g} (${classes.join(' vs ')}): ${res.error}`; + else { + if (res.winner != null) finished++; + totalTurns += res.turns; + } + } + check(`invariants hold across ${N} games`, !bad, bad || ''); + check('most games reach a decision', finished >= N * 0.9, `${finished}/${N}`); + if (!bad) console.log(` info avg turns/game: ${(totalTurns / N).toFixed(1)}`); +} + +// ── 3. Skill differentiation ───────────────────────────────────────────────── +console.log('Skill differentiation:'); +{ + const pairings = [ + { skills: [1, 10], minWin: 0.70 }, + { skills: [2, 8], minWin: 0.58 }, + { skills: [3, 6], minWin: 0.52 }, + { skills: [5, 7], minWin: 0.50 }, + ]; + const N = QUICK ? 10 : 30; + for (const { skills, minWin } of pairings) { + let highWins = 0, decided = 0; + for (let g = 0; g < N; g++) { + // alternate which seat the stronger AI takes to cancel seat bias + const flip = g % 2 === 1; + const seatSkills = flip ? [skills[1], skills[0]] : skills; + const res = playGame({ skills: seatSkills, classes: ['knight', 'knight'], seed: 5000 + skills[1] * 100 + g }); + if (res.error) { check(`skill ${skills[0]}v${skills[1]}`, false, res.error); decided = -1; break; } + if (res.winner == null) continue; + decided++; + const highSeat = flip ? 0 : 1; + if (res.winner === highSeat) highWins++; + } + if (decided > 0) { + const rate = highWins / decided; + check(`skill ${skills[1]} beats skill ${skills[0]} >= ${Math.round(minWin * 100)}%`, + rate >= minWin, `won ${(rate * 100).toFixed(0)}% (${highWins}/${decided})`); + } + } +} + +// ── 4. Class balance matrix (skill 5) ──────────────────────────────────────── +console.log('Class balance:'); +{ + const classIds = Object.keys(CLASSES); + const N = QUICK ? 2 : 8; + const wins = Object.fromEntries(classIds.map((c) => [c, 0])); + const games = Object.fromEntries(classIds.map((c) => [c, 0])); + let errors = 0; + for (const a of classIds) { + for (const b of classIds) { + if (a === b) continue; + for (let g = 0; g < N; g++) { + const res = playGame({ skills: [5, 5], classes: [a, b], seed: 9000 + classIds.indexOf(a) * 997 + classIds.indexOf(b) * 131 + g }); + if (res.error || res.winner == null) { errors++; continue; } + games[a]++; games[b]++; + wins[res.winner === 0 ? a : b]++; + } + } + } + for (const c of classIds) { + const rate = games[c] ? wins[c] / games[c] : 0; + console.log(` info ${CLASSES[c].name.padEnd(9)} win rate: ${(rate * 100).toFixed(0)}% (${wins[c]}/${games[c]})`); + if (!QUICK) { + check(`${CLASSES[c].name} within 25-75% band`, rate >= 0.25 && rate <= 0.75, + `${(rate * 100).toFixed(0)}%`); + } + } + check('class matrix games completed', errors === 0, `${errors} undecided/errored`); +} + +// ── 5. Ladder lint ─────────────────────────────────────────────────────────── +console.log('Ladder lint:'); +{ + const config = JSON.parse(readFileSync(join(__dirname, '../../public/data/jewelquest.json'), 'utf8')); + const roster = JSON.parse(readFileSync(join(__dirname, '../../public/data/opponents.json'), 'utf8')); + const rosterIds = new Set((Array.isArray(roster) ? roster : roster.opponents || []).map((o) => o.id)); + + const levels = config.levels || []; + check('ladder has levels', levels.length > 0); + check('levels contiguous from 1', levels.every((l, i) => l.level === i + 1)); + check('skills in 1-10', levels.every((l) => l.skill >= 1 && l.skill <= 10)); + check('hp positive', levels.every((l) => l.hp > 0)); + check('classes valid', levels.every((l) => CLASSES[l.class]), + levels.filter((l) => !CLASSES[l.class]).map((l) => l.class).join(',')); + check('spellCount in 1-5', levels.every((l) => l.spellCount >= 1 && l.spellCount <= 5)); + check('weights (when present) positive', levels.every((l) => + !l.weights || Object.values(l.weights).every((v) => typeof v === 'number' && v > 0))); + const unknown = levels.filter((l) => !rosterIds.has(l.opponentId)).map((l) => l.opponentId); + check('opponentIds exist in roster', unknown.length === 0, unknown.join(',')); + + const ms = config.milestones || []; + check('milestones sorted by afterLevel', ms.every((m, i) => i === 0 || ms[i - 1].afterLevel < m.afterLevel)); + check('milestones within ladder', ms.every((m) => m.afterLevel >= 1 && m.afterLevel <= levels.length)); + const slots = ms.filter((m) => m.unlockSpellSlot).map((m) => m.unlockSpellSlot); + check('unlock slots are 4 and 5, once each', slots.length === new Set(slots).size && slots.every((s) => s === 4 || s === 5)); + check('playerBaseHp positive', config.playerBaseHp > 0); + // every spell id referenced by classes exists + const missing = Object.values(CLASSES).flatMap((c) => c.spells).filter((id) => !SPELLS[id]); + check('all class spell ids defined', missing.length === 0, missing.join(',')); +} + +console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed'); +process.exit(failures ? 1 : 0);