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:
parent
bbb9c329c7
commit
d01a2917b1
Binary file not shown.
|
Before Width: | Height: | Size: 217 KiB After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
|
@ -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 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -68,6 +68,7 @@ import ShiftGame from './games/shift/ShiftGame.js';
|
||||||
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
||||||
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
||||||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||||
|
import JewelQuestGame from './games/jewelquest/JewelQuestGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -149,6 +150,7 @@ const config = {
|
||||||
BlockFighterGame,
|
BlockFighterGame,
|
||||||
MahjongMatchGame,
|
MahjongMatchGame,
|
||||||
MahjongGame,
|
MahjongGame,
|
||||||
|
JewelQuestGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
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]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('puddingmonsters', '/data/puddingmonsters.json');
|
this.load.json('puddingmonsters', '/data/puddingmonsters.json');
|
||||||
this.load.json('shift-artwork', '/data/shift-artwork.json');
|
this.load.json('shift-artwork', '/data/shift-artwork.json');
|
||||||
this.load.json('blockfighter', '/data/blockfighter.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-splash', '/assets/fx/water-splash.mp3');
|
||||||
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,11 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
|
||||||
scene.tweens.add({ targets, alpha: 0.2, duration });
|
scene.tweens.add({ targets, alpha: 0.2, duration });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let destroyed = false;
|
||||||
function destroy() {
|
function destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
scene.events.off('shutdown', destroy);
|
||||||
videoEl.pause();
|
videoEl.pause();
|
||||||
videoEl.src = '';
|
videoEl.src = '';
|
||||||
resetSpeechQueue();
|
resetSpeechQueue();
|
||||||
|
|
@ -216,6 +220,9 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
|
||||||
clearInterval(retargetTimer);
|
clearInterval(retargetTimer);
|
||||||
retargetTimer = null;
|
retargetTimer = null;
|
||||||
canvasDom.destroy();
|
canvasDom.destroy();
|
||||||
|
domEl.destroy();
|
||||||
|
backingG.destroy();
|
||||||
|
if (spriteImg) spriteImg.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
scene.events.once('shutdown', 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);
|
}).setOrigin(0.5).setDepth(depth + 1);
|
||||||
|
|
||||||
const allObjs = [backingG, placeholder];
|
const allObjs = [backingG, placeholder];
|
||||||
|
let destroyed = false;
|
||||||
|
|
||||||
// Async avatar load
|
// Async avatar load
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const { profile } = await api.get('/profile');
|
const { profile } = await api.get('/profile');
|
||||||
if (!profile?.avatarPath) return;
|
if (!profile?.avatarPath) return;
|
||||||
if (!scene.scene.isActive(sceneName)) return;
|
if (destroyed || !scene.scene.isActive(sceneName)) return;
|
||||||
|
|
||||||
const key = `player-avatar-${profile.id}`;
|
const key = `player-avatar-${profile.id}`;
|
||||||
if (!scene.textures.exists(key)) {
|
if (!scene.textures.exists(key)) {
|
||||||
|
|
@ -262,7 +270,7 @@ export function createPlayerPortrait(scene, worldX, worldY, radius, depth, scene
|
||||||
scene.load.start();
|
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 });
|
const maskG = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
maskG.fillStyle(0xffffff);
|
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 });
|
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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: '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: '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: '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 });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
Loading…
Reference in New Issue