diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index 2de7ec3..093b0f2 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index d79acfe..dfa7a47 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ 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);