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