feat: add Jewel Quest game and fix Portrait memory leaks

- Register new Jewel Quest game across client and server
- Update game icons spritesheet with new frame (59)
- Fix memory leaks in Portrait components by implementing proper
  cleanup with double-destruction guards and returning destroy function
This commit is contained in:
Brian Fertig 2026-06-11 19:07:43 -06:00
parent bbb9c329c7
commit d01a2917b1
14 changed files with 2918 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

View File

@ -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 } }
]
}

View File

@ -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;
}

View File

@ -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'|<color> }
// { kind:'stealMana', amount, color:'largest'|<color> }
// { kind:'destroyGems', selector } selector: { mode:'random', count } |
// { mode:'color', color } | { mode:'skulls', harmless? } |
// { mode:'column' } | { mode:'row' }
// { kind:'transformGems', from:'random'|<color>, 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'],
},
};

File diff suppressed because it is too large Load Diff

View File

@ -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<r*SIZE+c>, 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);
}

View File

@ -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.

View File

@ -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,
],
};

View File

@ -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,

View File

@ -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');

View File

@ -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 };
}

View File

@ -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 });

View File

@ -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);