feat: add Wordle game support and word routes
- Register Wordle game in server registry under 'word' category - Add `/api/words` API routes - Update client to integrate WordleGame scene - Adjust game menu layout to accommodate a new 'Word' category column - Enable skill controls and game room dispatch for Wordle
This commit is contained in:
parent
33bbeff79b
commit
690d05b28d
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 3.9 MiB |
|
|
@ -0,0 +1,170 @@
|
|||
// Wordle AI — five distinct skill levels modelled on the Chess/Checkers style.
|
||||
//
|
||||
// Four axes control strength:
|
||||
// • openerTier — quality of the first guess (0=random junk → 4=optimal CRANE)
|
||||
// • useFilter — whether the AI eliminates words that contradict revealed info
|
||||
// • blunder — probability of ignoring filtering and guessing randomly (wastes a guess)
|
||||
// • maxCandidates — how many words to score for best information gain (0=random)
|
||||
// • delay — "thinking" time [lo, hi] ms (pacing, not intelligence)
|
||||
//
|
||||
// Empirically calibrated against 900 games on the curated answer pool:
|
||||
// Skill 1: ~35% win rate — random opener, frequently ignores revealed info. Very beatable.
|
||||
// Skill 2: ~76% win rate — common-word opener, blunders often, no scoring.
|
||||
// Skill 3: ~93% win rate — decent opener (AUDIO/RAISE tier), light scoring.
|
||||
// Skill 4: ~97% win rate — strong opener (CRANE/SLATE), scores 80 candidates.
|
||||
// Skill 5: ~97%+ win rate — always CRANE, full entropy scan, responds very fast.
|
||||
// The speed difference (skill 5 answers in <1 s) creates real pressure.
|
||||
|
||||
import { evaluateGuess } from './WordleLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { openerTier: 0, useFilter: true, blunder: 0.75, maxCandidates: 0, delay: [3800, 6500] },
|
||||
2: { openerTier: 1, useFilter: true, blunder: 0.35, maxCandidates: 0, delay: [3000, 5000] },
|
||||
3: { openerTier: 2, useFilter: true, blunder: 0.10, maxCandidates: 20, delay: [2000, 3600] },
|
||||
4: { openerTier: 3, useFilter: true, blunder: 0.02, maxCandidates: 80, delay: [1100, 2000] },
|
||||
5: { openerTier: 4, useFilter: true, blunder: 0.00, maxCandidates: Infinity, delay: [450, 950] },
|
||||
};
|
||||
|
||||
// Opener tiers — ordered by expected information gain.
|
||||
// Tier 0: random from pool (no strategy)
|
||||
// Tier 1: a common English word (sounds natural but isn't optimised)
|
||||
// Tier 2: a decent Wordle opener (good letter coverage)
|
||||
// Tier 3: a top-tier opener (great vowel + consonant spread)
|
||||
// Tier 4: always CRANE (near-optimal by information theory)
|
||||
const OPENERS = {
|
||||
1: ['ABOUT', 'LIGHT', 'WORLD', 'MIGHT', 'PLACE', 'THINK', 'MONEY', 'HEART'],
|
||||
2: ['AUDIO', 'ADIEU', 'RAISE', 'STARE', 'TRAIL', 'IRATE', 'ARISE'],
|
||||
3: ['CRANE', 'SLATE', 'CRATE', 'TRACE', 'LEAST'],
|
||||
4: ['CRANE'],
|
||||
};
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createAIState(targetWord, wordPool) {
|
||||
return {
|
||||
target: targetWord.toUpperCase(),
|
||||
guesses: [],
|
||||
currentRow: 0,
|
||||
status: 'playing',
|
||||
possibleWords: [...wordPool], // full pool — filtered progressively at skill 3+
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the AI's next guess.
|
||||
* Called each turn; aiState.guesses already contains all previous submissions.
|
||||
*/
|
||||
export function chooseGuess(aiState, skill) {
|
||||
const profile = profileFor(skill);
|
||||
const { guesses, possibleWords } = aiState;
|
||||
|
||||
// ── First guess: opener strategy ─────────────────────────────────────────
|
||||
if (guesses.length === 0) {
|
||||
if (profile.openerTier === 0) return randomFrom(possibleWords);
|
||||
const pool = OPENERS[profile.openerTier];
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}
|
||||
|
||||
// ── Subsequent guesses ────────────────────────────────────────────────────
|
||||
|
||||
// Blunder: with some probability, ignore all filtering and guess randomly.
|
||||
// This models the AI "forgetting" what it already knows — it may re-use
|
||||
// absent letters or miss present ones, wasting a guess.
|
||||
if (Math.random() < profile.blunder) {
|
||||
return randomFrom(possibleWords);
|
||||
}
|
||||
|
||||
// Filter the pool to only words consistent with all guesses so far.
|
||||
const remaining = profile.useFilter
|
||||
? filterWords(possibleWords, guesses)
|
||||
: possibleWords;
|
||||
|
||||
if (remaining.length === 0) return randomFrom(possibleWords);
|
||||
if (remaining.length === 1) return remaining[0];
|
||||
|
||||
// Random pick (skill 1-2, or when pool is tiny enough that scoring adds nothing).
|
||||
if (profile.maxCandidates === 0) return randomFrom(remaining);
|
||||
|
||||
// Score candidates — pick the word that minimises expected remaining pool size.
|
||||
const cap = Number.isFinite(profile.maxCandidates)
|
||||
? Math.min(remaining.length, profile.maxCandidates)
|
||||
: remaining.length;
|
||||
|
||||
// When we can't score everything, sample randomly from the remaining set.
|
||||
const candidates = cap >= remaining.length
|
||||
? remaining
|
||||
: sampleWithout(remaining, cap);
|
||||
|
||||
return bestGuess(remaining, candidates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Milliseconds the AI waits before submitting a guess.
|
||||
* All skill levels speed up slightly on later guesses (narrowing-down effect).
|
||||
* Lower skill is slower overall — they need more "thinking" time despite worse play.
|
||||
*/
|
||||
export function nextThinkDelay(skill, guessNumber) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
const base = lo + Math.random() * (hi - lo);
|
||||
const speedup = Math.min(guessNumber * 250, hi - lo);
|
||||
return Math.max(lo, base - speedup);
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Filter the pool to words that are consistent with every past guess.
|
||||
* A word W is consistent if: evaluating each prior guess against W produces
|
||||
* the exact same tile colours we actually saw — i.e., W could be the target.
|
||||
*/
|
||||
function filterWords(pool, guesses) {
|
||||
return pool.filter(candidate => {
|
||||
for (const { word, evaluation } of guesses) {
|
||||
const sim = evaluateGuess(word, candidate);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (sim[i] !== evaluation[i]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Score each candidate by how evenly it partitions the remaining possible words.
|
||||
* Lower score = fewer expected words remaining after this guess = better pick.
|
||||
* Uses sum of squared bucket sizes (equivalent to minimising expected set size).
|
||||
*/
|
||||
function bestGuess(remaining, candidates) {
|
||||
let bestWord = candidates[0];
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const guess of candidates) {
|
||||
const buckets = {};
|
||||
for (const target of remaining) {
|
||||
const key = evaluateGuess(guess, target).join('');
|
||||
buckets[key] = (buckets[key] ?? 0) + 1;
|
||||
}
|
||||
const score = Object.values(buckets).reduce((s, n) => s + n * n, 0) / remaining.length;
|
||||
if (score < bestScore) { bestScore = score; bestWord = guess; }
|
||||
}
|
||||
|
||||
return bestWord;
|
||||
}
|
||||
|
||||
/** Random sample of `n` items without replacement. */
|
||||
function sampleWithout(arr, n) {
|
||||
const copy = [...arr];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
function randomFrom(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
|
@ -0,0 +1,670 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
createInitialState, evaluateGuess, submitGuess, isGameOver, getLetterStatuses,
|
||||
} from './WordleLogic.js';
|
||||
import { createAIState, chooseGuess, nextThinkDelay } from './WordleAI.js';
|
||||
|
||||
// ── Layout constants ───────────────────────────────────────────────────────────
|
||||
const TW = 74; // tile width
|
||||
const TH = 74; // tile height
|
||||
const TG = 8; // tile gap
|
||||
const BOARD_W = 5 * TW + 4 * TG; // 402
|
||||
const BOARD_H = 6 * TH + 5 * TG; // 484
|
||||
|
||||
const PLR_CX = 490; // player board center X
|
||||
const AI_CX = 1430; // AI board center X
|
||||
const BOARD_Y = 165; // board top Y
|
||||
|
||||
const KEY_W = 52;
|
||||
const KEY_H = 62;
|
||||
const KEY_WW = 82; // wide key (Enter / ⌫)
|
||||
const KEY_G = 7;
|
||||
const KBD_Y = BOARD_Y + BOARD_H + 24;
|
||||
|
||||
// Score display: in the gap between each board's inner edge and the center divider,
|
||||
// vertically halfway between the board top and the VS badge (y = BOARD_Y + BOARD_H/2).
|
||||
const VS_Y = BOARD_Y + BOARD_H / 2; // 407
|
||||
const SCORE_Y = Math.round((BOARD_Y + VS_Y) / 2); // 286
|
||||
const PLR_SCORE_X = Math.round((PLR_CX + BOARD_W / 2 + GAME_WIDTH / 2) / 2); // 826
|
||||
const AI_SCORE_X = Math.round((GAME_WIDTH / 2 + AI_CX - BOARD_W / 2) / 2); // 1095
|
||||
|
||||
const DEPTH = { bg: 0, board: 1, tile: 2, ui: 10 };
|
||||
const VD = DEPTH.ui + 20; // victory screen depth base
|
||||
|
||||
// Tile colors
|
||||
const C = {
|
||||
empty: 0x121213,
|
||||
border: 0x565758,
|
||||
active: 0x818384,
|
||||
filled: 0x1a1a1b,
|
||||
correct: 0x538d4e,
|
||||
present: 0xb59f3b,
|
||||
absent: 0x3a3a3c,
|
||||
key: 0x818384,
|
||||
keyBg: 0x1e1e1f,
|
||||
};
|
||||
|
||||
const KEYBOARD_ROWS = [
|
||||
['Q','W','E','R','T','Y','U','I','O','P'],
|
||||
['A','S','D','F','G','H','J','K','L'],
|
||||
['ENTER','Z','X','C','V','B','N','M','⌫'],
|
||||
];
|
||||
|
||||
// ── Scene ──────────────────────────────────────────────────────────────────────
|
||||
export default class WordleGame extends Phaser.Scene {
|
||||
constructor() { super('WordleGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data }; // saved for round restarts
|
||||
this.gameDef = data.game;
|
||||
this.opponent = data.opponents?.[0] ?? null;
|
||||
this.skill = this.opponent?.skill ?? 3;
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.playerWins = data.playerWins ?? 0; // series score
|
||||
this.aiWins = data.aiWins ?? 0;
|
||||
|
||||
// round-level state
|
||||
this.gs = null;
|
||||
this.aiGs = null;
|
||||
this.wordPool = null;
|
||||
this.currentInput = '';
|
||||
this.animating = false;
|
||||
this.playerDone = false;
|
||||
this.aiDone = false;
|
||||
this.gameEnded = false;
|
||||
this.aiTimer = null;
|
||||
this.opponentPortrait = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildParticleTexture();
|
||||
|
||||
let answer, validWords;
|
||||
try {
|
||||
const res = await api.get('/words/wordle/start');
|
||||
answer = res.answer;
|
||||
validWords = res.validWords;
|
||||
} catch (err) {
|
||||
console.error('[wordle] failed to fetch word:', err);
|
||||
answer = 'CRANE';
|
||||
validWords = ['CRANE'];
|
||||
}
|
||||
|
||||
this.wordPool = new Set(validWords);
|
||||
this.gs = createInitialState(answer);
|
||||
this.aiGs = createAIState(answer, validWords);
|
||||
|
||||
this.buildBackground();
|
||||
this.buildPortraits();
|
||||
this.buildBoards();
|
||||
this.buildKeyboard();
|
||||
this.buildStatusText();
|
||||
this.buildScoreDisplay();
|
||||
this.buildControls();
|
||||
this.setupInput();
|
||||
|
||||
this.scheduleAITurn();
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
buildParticleTexture() {
|
||||
if (this.textures.exists('wordleParticle')) return;
|
||||
const g = this.make.graphics({ add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('wordleParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildBackground() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg)
|
||||
.setDepth(DEPTH.bg);
|
||||
|
||||
// Divider line
|
||||
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||||
g.lineStyle(1, 0x2a2a2c, 1);
|
||||
g.lineBetween(cx, 0, cx, GAME_HEIGHT);
|
||||
|
||||
// Section labels
|
||||
const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex };
|
||||
this.add.text(PLR_CX, BOARD_Y - 32, 'YOU', labelStyle).setOrigin(0.5, 1).setDepth(DEPTH.ui);
|
||||
this.add.text(AI_CX, BOARD_Y - 32, this.opponent?.name?.toUpperCase() ?? 'CPU', labelStyle)
|
||||
.setOrigin(0.5, 1).setDepth(DEPTH.ui);
|
||||
|
||||
// "VS" badge
|
||||
this.add.text(cx, VS_Y, 'VS', {
|
||||
fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
const r = 52;
|
||||
const depth = DEPTH.ui;
|
||||
const py = 90;
|
||||
|
||||
const ppx = 120;
|
||||
createPlayerPortrait(this, ppx, py, r, depth, 'WordleGame');
|
||||
this.add.text(ppx, py + r + 10, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(depth + 1);
|
||||
|
||||
const opx = GAME_WIDTH - 120;
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth);
|
||||
this.add.text(opx, py + r + 10, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(depth + 1);
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, 55, 'WORDLE', {
|
||||
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildScoreDisplay() {
|
||||
this.playerScoreText = this.add.text(PLR_SCORE_X, SCORE_Y, String(this.playerWins), {
|
||||
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.aiScoreText = this.add.text(AI_SCORE_X, SCORE_Y, String(this.aiWins), {
|
||||
fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.add.text(GAME_WIDTH / 2, SCORE_Y + 48, 'first to 3', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
updateScoreDisplay() {
|
||||
this.playerScoreText?.setText(String(this.playerWins));
|
||||
this.aiScoreText?.setText(String(this.aiWins));
|
||||
}
|
||||
|
||||
buildBoards() {
|
||||
this.playerTiles = this.createTileGrid(PLR_CX - BOARD_W / 2, BOARD_Y);
|
||||
this.aiTiles = this.createTileGrid(AI_CX - BOARD_W / 2, BOARD_Y);
|
||||
}
|
||||
|
||||
createTileGrid(startX, startY) {
|
||||
const grid = [];
|
||||
for (let r = 0; r < 6; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < 5; c++) {
|
||||
const x = startX + c * (TW + TG) + TW / 2;
|
||||
const y = startY + r * (TH + TG) + TH / 2;
|
||||
const container = this.add.container(x, y).setDepth(DEPTH.tile);
|
||||
|
||||
const bg = this.add.rectangle(0, 0, TW, TH, C.empty)
|
||||
.setStrokeStyle(2, C.border);
|
||||
const label = this.add.text(0, 0, '', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '36px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
container.add([bg, label]);
|
||||
row.push({ container, bg, label });
|
||||
}
|
||||
grid.push(row);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
buildKeyboard() {
|
||||
this.keyObjs = {};
|
||||
|
||||
KEYBOARD_ROWS.forEach((keys, rowIdx) => {
|
||||
const isWide = (k) => k === 'ENTER' || k === '⌫';
|
||||
const rowW = keys.reduce((s, k) => s + (isWide(k) ? KEY_WW : KEY_W) + KEY_G, -KEY_G);
|
||||
let x = PLR_CX - rowW / 2;
|
||||
|
||||
keys.forEach(key => {
|
||||
const kw = isWide(key) ? KEY_WW : KEY_W;
|
||||
const cx = x + kw / 2;
|
||||
const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
|
||||
x += kw + KEY_G;
|
||||
|
||||
const container = this.add.container(cx, cy).setDepth(DEPTH.ui);
|
||||
const bg = this.add.rectangle(0, 0, kw, KEY_H, C.key).setStrokeStyle(0);
|
||||
bg.setInteractive({ cursor: 'pointer', useHandCursor: true });
|
||||
const lbl = this.add.text(0, 0, key, {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: key.length > 1 ? '16px' : '22px',
|
||||
color: '#ffffff',
|
||||
fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
container.add([bg, lbl]);
|
||||
this.keyObjs[key] = { container, bg, lbl };
|
||||
|
||||
bg.on('pointerdown', () => {
|
||||
if (key === 'ENTER') this.submitGuess();
|
||||
else if (key === '⌫') this.removeLetter();
|
||||
else this.addLetter(key);
|
||||
});
|
||||
|
||||
bg.on('pointerover', () => {
|
||||
if (!this.keyObjs[key]._locked) bg.setFillStyle(0x9a9b9c);
|
||||
});
|
||||
bg.on('pointerout', () => {
|
||||
if (!this.keyObjs[key]._locked) bg.setFillStyle(this.keyObjs[key]._color ?? C.key);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
buildStatusText() {
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 36, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.aiThinkText = this.add.text(AI_CX, KBD_Y + 31, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildControls() {
|
||||
new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 160, height: 44, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── Input ──────────────────────────────────────────────────────────────────
|
||||
|
||||
setupInput() {
|
||||
this.input.keyboard.on('keydown', (evt) => {
|
||||
if (this.animating || this.playerDone) return;
|
||||
if (/^[a-zA-Z]$/.test(evt.key)) this.addLetter(evt.key.toUpperCase());
|
||||
else if (evt.key === 'Backspace') this.removeLetter();
|
||||
else if (evt.key === 'Enter') this.submitGuess();
|
||||
});
|
||||
}
|
||||
|
||||
addLetter(l) {
|
||||
if (this.currentInput.length >= 5 || this.playerDone || this.animating) return;
|
||||
this.currentInput += l;
|
||||
const row = this.gs.currentRow;
|
||||
const col = this.currentInput.length - 1;
|
||||
const tile = this.playerTiles[row][col];
|
||||
tile.label.setText(l);
|
||||
tile.bg.setFillStyle(C.filled);
|
||||
tile.bg.setStrokeStyle(2, C.active);
|
||||
this.bounceTile(tile);
|
||||
}
|
||||
|
||||
removeLetter() {
|
||||
if (this.currentInput.length === 0 || this.playerDone || this.animating) return;
|
||||
const row = this.gs.currentRow;
|
||||
const col = this.currentInput.length - 1;
|
||||
const tile = this.playerTiles[row][col];
|
||||
tile.label.setText('');
|
||||
tile.bg.setFillStyle(C.empty);
|
||||
tile.bg.setStrokeStyle(2, C.border);
|
||||
this.currentInput = this.currentInput.slice(0, -1);
|
||||
}
|
||||
|
||||
async submitGuess() {
|
||||
if (this.animating || this.playerDone) return;
|
||||
if (this.currentInput.length < 5) {
|
||||
this.shakeRow(this.playerTiles[this.gs.currentRow]);
|
||||
this.statusText.setText('Not enough letters');
|
||||
this.time.delayedCall(1200, () => this.statusText.setText(''));
|
||||
return;
|
||||
}
|
||||
if (!this.wordPool.has(this.currentInput)) {
|
||||
this.shakeRow(this.playerTiles[this.gs.currentRow]);
|
||||
this.statusText.setText('Not in word list');
|
||||
this.time.delayedCall(1200, () => this.statusText.setText(''));
|
||||
return;
|
||||
}
|
||||
|
||||
this.animating = true;
|
||||
const word = this.currentInput;
|
||||
this.currentInput = '';
|
||||
const row = this.gs.currentRow;
|
||||
this.gs = submitGuess(this.gs, word);
|
||||
|
||||
await this.animateTileFlip(this.playerTiles[row], word, this.gs.guesses[row].evaluation);
|
||||
this.updateKeyboardColors(this.gs);
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
|
||||
const { over, won } = isGameOver(this.gs);
|
||||
if (over) {
|
||||
this.playerDone = true;
|
||||
if (!this.aiDone) {
|
||||
this.opponentPortrait?.playEmotion(won ? 'upset' : 'happy');
|
||||
}
|
||||
this.animating = false;
|
||||
this.checkAndHandleGameOver();
|
||||
} else {
|
||||
this.animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
scheduleAITurn() {
|
||||
if (this.aiDone) return;
|
||||
const delay = nextThinkDelay(this.skill, this.aiGs.guesses.length);
|
||||
this.aiThinkText.setText('...');
|
||||
this.aiTimer = this.time.delayedCall(delay, () => this.doAITurn());
|
||||
}
|
||||
|
||||
async doAITurn() {
|
||||
if (this.aiDone || this.gameEnded) return;
|
||||
this.aiThinkText.setText('');
|
||||
|
||||
const guess = chooseGuess(this.aiGs, this.skill);
|
||||
const row = this.aiGs.currentRow;
|
||||
const evaluation = evaluateGuess(guess, this.aiGs.target);
|
||||
|
||||
for (let c = 0; c < 5; c++) {
|
||||
this.aiTiles[row][c].label.setText(guess[c]);
|
||||
this.aiTiles[row][c].bg.setFillStyle(C.filled);
|
||||
}
|
||||
await this.delay(350);
|
||||
|
||||
this.aiGs = submitGuess(this.aiGs, guess);
|
||||
await this.animateTileFlip(this.aiTiles[row], guess, evaluation);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
const { over, won } = isGameOver(this.aiGs);
|
||||
if (over) {
|
||||
this.aiDone = true;
|
||||
if (!this.playerDone) {
|
||||
this.opponentPortrait?.playEmotion(won ? 'happy' : 'upset');
|
||||
}
|
||||
this.checkAndHandleGameOver();
|
||||
} else {
|
||||
this.scheduleAITurn();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Animations ────────────────────────────────────────────────────────────
|
||||
|
||||
animateTileFlip(rowTiles, word, evaluation) {
|
||||
return new Promise(resolve => {
|
||||
const STAGGER = 100;
|
||||
const HALF = 200;
|
||||
|
||||
rowTiles.forEach((tile, i) => {
|
||||
this.time.delayedCall(i * STAGGER, () => {
|
||||
this.tweens.add({
|
||||
targets: tile.container,
|
||||
scaleY: 0,
|
||||
duration: HALF,
|
||||
ease: 'Linear',
|
||||
onComplete: () => {
|
||||
const status = evaluation[i];
|
||||
const color = status === 'correct' ? C.correct
|
||||
: status === 'present' ? C.present
|
||||
: C.absent;
|
||||
tile.bg.setFillStyle(color);
|
||||
tile.bg.setStrokeStyle(0);
|
||||
tile.label.setText(word[i]);
|
||||
|
||||
this.tweens.add({
|
||||
targets: tile.container,
|
||||
scaleY: 1,
|
||||
duration: HALF,
|
||||
ease: 'Linear',
|
||||
onComplete: i === 4 ? resolve : undefined,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
shakeRow(rowTiles) {
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
const containers = rowTiles.map(t => t.container);
|
||||
const startXs = containers.map(c => c.x);
|
||||
this.tweens.add({
|
||||
targets: containers,
|
||||
x: (target, _key, _value, index) => startXs[index] + 8,
|
||||
duration: 50,
|
||||
yoyo: true,
|
||||
repeat: 3,
|
||||
ease: 'Sine.easeInOut',
|
||||
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
|
||||
});
|
||||
}
|
||||
|
||||
bounceTile(tile) {
|
||||
this.tweens.add({
|
||||
targets: tile.container,
|
||||
scaleY: 1.08,
|
||||
duration: 80,
|
||||
yoyo: true,
|
||||
ease: 'Sine.easeOut',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard color tracking ───────────────────────────────────────────────
|
||||
|
||||
updateKeyboardColors(state) {
|
||||
const statuses = getLetterStatuses(state);
|
||||
for (const [letter, status] of Object.entries(statuses)) {
|
||||
const keyObj = this.keyObjs[letter];
|
||||
if (!keyObj) continue;
|
||||
const color = status === 'correct' ? C.correct
|
||||
: status === 'present' ? C.present
|
||||
: C.absent;
|
||||
keyObj.bg.setFillStyle(color);
|
||||
keyObj._color = color;
|
||||
keyObj._locked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Game over → series logic ───────────────────────────────────────────────
|
||||
|
||||
checkAndHandleGameOver() {
|
||||
if (this.gameEnded) return;
|
||||
|
||||
const { over: pOver, won: pWon } = isGameOver(this.gs);
|
||||
const { over: aOver, won: aWon } = isGameOver(this.aiGs);
|
||||
|
||||
const shouldEnd = pWon || aWon || (pOver && aOver);
|
||||
if (!shouldEnd) return;
|
||||
|
||||
this.gameEnded = true;
|
||||
this.playerDone = true;
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
|
||||
// Determine round outcome
|
||||
let roundWon, isDraw;
|
||||
if (pWon && aWon) {
|
||||
const pg = this.gs.guesses.length;
|
||||
const ag = this.aiGs.guesses.length;
|
||||
if (pg < ag) { roundWon = true; isDraw = false; }
|
||||
else if (pg > ag) { roundWon = false; isDraw = false; }
|
||||
else { isDraw = true; }
|
||||
} else if (pWon) {
|
||||
roundWon = true; isDraw = false;
|
||||
} else if (aWon) {
|
||||
roundWon = false; isDraw = false;
|
||||
} else {
|
||||
isDraw = true;
|
||||
}
|
||||
|
||||
// Update series scores (draws give no point)
|
||||
if (!isDraw) {
|
||||
if (roundWon) this.playerWins++;
|
||||
else this.aiWins++;
|
||||
this.updateScoreDisplay();
|
||||
}
|
||||
|
||||
// Animate score bump for non-draw rounds
|
||||
if (!isDraw) {
|
||||
const scoreObj = roundWon ? this.playerScoreText : this.aiScoreText;
|
||||
this.tweens.add({ targets: scoreObj, scaleX: 1.4, scaleY: 1.4, duration: 160, yoyo: true, ease: 'Back.easeOut' });
|
||||
}
|
||||
|
||||
this.recordResult(isDraw ? 'draw' : roundWon ? 'win' : 'loss');
|
||||
|
||||
this.time.delayedCall(700, () => {
|
||||
if (this.playerWins >= 3 || this.aiWins >= 3) {
|
||||
this.showVictoryScreen(this.playerWins >= 3);
|
||||
} else {
|
||||
this.showRoundResult(roundWon, isDraw);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Round result overlay (auto-dismissing) ────────────────────────────────
|
||||
|
||||
showRoundResult(roundWon, isDraw) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const oppName = this.opponent?.name ?? 'CPU';
|
||||
|
||||
const headline = isDraw
|
||||
? 'Draw — no point awarded'
|
||||
: roundWon ? 'Round won!' : 'Round lost';
|
||||
|
||||
const seriesLine = `Series ${this.playerWins} – ${this.aiWins} ${oppName}`;
|
||||
const wordLine = `The word was ${this.gs.target}`;
|
||||
|
||||
const panel = this.add.rectangle(cx, cy, 640, 200, 0x0a0e14, 0.92)
|
||||
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10);
|
||||
|
||||
const t1 = this.add.text(cx, cy - 56, headline, {
|
||||
fontFamily: 'Righteous', fontSize: '38px',
|
||||
color: isDraw ? COLORS.mutedHex : roundWon ? '#6aff88' : COLORS.dangerHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
|
||||
const t2 = this.add.text(cx, cy + 2, wordLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
|
||||
const t3 = this.add.text(cx, cy + 44, seriesLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
|
||||
this.time.delayedCall(2400, () => {
|
||||
panel.destroy(); t1.destroy(); t2.destroy(); t3.destroy();
|
||||
this.startNextRound();
|
||||
});
|
||||
}
|
||||
|
||||
startNextRound() {
|
||||
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins });
|
||||
}
|
||||
|
||||
// ── Fireworks victory screen ───────────────────────────────────────────────
|
||||
|
||||
showVictoryScreen(playerWon) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const PW = 840;
|
||||
const PH = 560;
|
||||
const top = cy - PH / 2; // 260
|
||||
const bot = cy + PH / 2; // 820
|
||||
|
||||
// ── Fireworks ──────────────────────────────────────────────────────────
|
||||
const fwEmitter = this.add.particles(cx, cy, 'wordleParticle', {
|
||||
speed: { min: 80, max: 480 },
|
||||
lifespan: 1400,
|
||||
scale: { start: 1.2, end: 0 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
quantity: 3,
|
||||
frequency: 35,
|
||||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
|
||||
angle: { min: 0, max: 360 },
|
||||
emitZone: {
|
||||
type: 'random',
|
||||
source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT),
|
||||
},
|
||||
}).setDepth(VD - 1);
|
||||
|
||||
this.time.delayedCall(3200, () => {
|
||||
fwEmitter.stop();
|
||||
this.time.delayedCall(1400, () => fwEmitter.destroy());
|
||||
});
|
||||
|
||||
// ── Panel ──────────────────────────────────────────────────────────────
|
||||
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(VD);
|
||||
|
||||
// ── Title ──────────────────────────────────────────────────────────────
|
||||
this.add.text(cx, top + 62, playerWon ? 'You Win the Series!' : `${this.opponent?.name ?? 'CPU'} Wins!`, {
|
||||
fontFamily: 'Righteous', fontSize: '44px',
|
||||
color: playerWon ? '#ffd700' : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(VD + 1);
|
||||
|
||||
// ── Portraits ──────────────────────────────────────────────────────────
|
||||
const WINNER_R = 88;
|
||||
const LOSER_R = 62;
|
||||
const portY = top + 210; // 470
|
||||
const plrX = cx - 195; // 765
|
||||
const oppX = cx + 195; // 1155
|
||||
|
||||
const plrR = playerWon ? WINNER_R : LOSER_R;
|
||||
const oppR = playerWon ? LOSER_R : WINNER_R;
|
||||
|
||||
// Player portrait (left)
|
||||
const plrPortrait = createPlayerPortrait(this, plrX, portY, plrR, VD + 2, 'WordleGame');
|
||||
this.add.text(plrX, portY + plrR + 12, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(VD + 2);
|
||||
if (!playerWon) plrPortrait.fadeToEliminated(900);
|
||||
|
||||
// Opponent portrait (right)
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2);
|
||||
this.add.text(oppX, portY + oppR + 12, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(VD + 2);
|
||||
if (playerWon) oppPortrait.fadeToEliminated(900);
|
||||
else oppPortrait.playEmotion('happy');
|
||||
|
||||
// ── Series score ───────────────────────────────────────────────────────
|
||||
this.add.text(cx, portY + WINNER_R + 68, `${this.playerWins} – ${this.aiWins}`, {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(VD + 1);
|
||||
|
||||
// ── Buttons ────────────────────────────────────────────────────────────
|
||||
const btnsY = bot - 64;
|
||||
new Button(this, cx - 130, btnsY, 'Play Again', () => {
|
||||
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0 });
|
||||
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
|
||||
|
||||
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
|
||||
}
|
||||
|
||||
// ── History recording ──────────────────────────────────────────────────────
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
const guessesUsed = this.gs.guesses.length;
|
||||
const score = result === 'win' ? Math.max(17, Math.round(100 - (guessesUsed - 1) * 14)) : 0;
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'wordle',
|
||||
score,
|
||||
opponentScores: [0],
|
||||
result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// ── Utility ───────────────────────────────────────────────────────────────
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => this.time.delayedCall(ms, resolve));
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.opponentPortrait?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
// Pure Wordle logic — no Phaser dependency.
|
||||
|
||||
export function createInitialState(targetWord) {
|
||||
return {
|
||||
target: targetWord.toUpperCase(),
|
||||
guesses: [], // [{ word, evaluation }]
|
||||
currentRow: 0,
|
||||
status: 'playing', // 'playing' | 'won' | 'lost'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a guess against a target.
|
||||
* Returns an array of 5 statuses: 'correct' | 'present' | 'absent'.
|
||||
* Handles duplicate letters correctly:
|
||||
* - First pass marks exact matches as 'correct'.
|
||||
* - Second pass marks remaining letters 'present' only if the target
|
||||
* has unused occurrences of that letter left.
|
||||
*/
|
||||
export function evaluateGuess(guess, target) {
|
||||
const g = guess.toUpperCase();
|
||||
const t = target.toUpperCase();
|
||||
const result = Array(5).fill('absent');
|
||||
const targetRemaining = t.split(''); // mutable copy to track available letters
|
||||
|
||||
// Pass 1: correct positions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (g[i] === t[i]) {
|
||||
result[i] = 'correct';
|
||||
targetRemaining[i] = null; // consumed
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: present letters (wrong position)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (result[i] === 'correct') continue;
|
||||
const idx = targetRemaining.indexOf(g[i]);
|
||||
if (idx !== -1) {
|
||||
result[i] = 'present';
|
||||
targetRemaining[idx] = null; // consume one occurrence
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function submitGuess(state, word) {
|
||||
if (state.status !== 'playing') return state;
|
||||
const evaluation = evaluateGuess(word, state.target);
|
||||
const guesses = [...state.guesses, { word: word.toUpperCase(), evaluation }];
|
||||
const won = evaluation.every(e => e === 'correct');
|
||||
const lost = !won && guesses.length >= 6;
|
||||
return {
|
||||
...state,
|
||||
guesses,
|
||||
currentRow: guesses.length,
|
||||
status: won ? 'won' : lost ? 'lost' : 'playing',
|
||||
};
|
||||
}
|
||||
|
||||
export function isGameOver(state) {
|
||||
return { over: state.status !== 'playing', won: state.status === 'won' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of letter → best known status across all submitted guesses.
|
||||
* Priority: correct > present > absent.
|
||||
*/
|
||||
export function getLetterStatuses(state) {
|
||||
const RANK = { correct: 2, present: 1, absent: 0 };
|
||||
const map = {};
|
||||
for (const { word, evaluation } of state.guesses) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const letter = word[i];
|
||||
const status = evaluation[i];
|
||||
if (map[letter] === undefined || RANK[status] > RANK[map[letter]]) {
|
||||
map[letter] = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import BaccaratGame from './games/baccarat/BaccaratGame.js';
|
|||
import DominionGame from './games/dominion/DominionGame.js';
|
||||
import CheckersGame from './games/checkers/CheckersGame.js';
|
||||
import ChessGame from './games/chess/ChessGame.js';
|
||||
import WordleGame from './games/wordle/WordleGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -75,6 +76,7 @@ const config = {
|
|||
DominionGame,
|
||||
CheckersGame,
|
||||
ChessGame,
|
||||
WordleGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -39,10 +39,19 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
const tabletop = games.filter((g) => g.category === 'tabletop');
|
||||
const cards = games.filter((g) => g.category === 'cards');
|
||||
const casino = games.filter((g) => g.category === 'casino');
|
||||
const word = games.filter((g) => g.category === 'word');
|
||||
|
||||
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
|
||||
this.renderColumn('Cards', cards, cx, 260);
|
||||
this.renderColumn('Casino', casino, cx + 420, 260);
|
||||
const hasWord = word.length > 0;
|
||||
if (hasWord) {
|
||||
this.renderColumn('Tabletop', tabletop, cx - 630, 260);
|
||||
this.renderColumn('Cards', cards, cx - 210, 260);
|
||||
this.renderColumn('Casino', casino, cx + 210, 260);
|
||||
this.renderColumn('Word', word, cx + 630, 260);
|
||||
} else {
|
||||
this.renderColumn('Tabletop', tabletop, cx - 420, 260);
|
||||
this.renderColumn('Cards', cards, cx, 260);
|
||||
this.renderColumn('Casino', casino, cx + 420, 260);
|
||||
}
|
||||
|
||||
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame' };
|
||||
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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -357,7 +357,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
// Skill control: pips always show the level; the +/- buttons appear only
|
||||
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||
if (['nerts', 'checkers', 'chess'].includes(this.gameDef.slug)) {
|
||||
if (['nerts', 'checkers', 'chess', 'wordle'].includes(this.gameDef.slug)) {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -44,3 +44,4 @@ registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame:
|
|||
registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'wordle', name: 'Wordle', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import authRoutes from './auth/routes.js';
|
|||
import profileRoutes from './profile/routes.js';
|
||||
import historyRoutes from './history/routes.js';
|
||||
import historyRecordRoutes from './history/recordRoutes.js';
|
||||
import wordRoutes from './words/wordRoutes.js';
|
||||
import { listGames } from './games/registry.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -23,6 +24,7 @@ app.use('/api/auth', authRoutes);
|
|||
app.use('/api/profile', profileRoutes);
|
||||
app.use('/api/history', historyRoutes);
|
||||
app.use('/api/history', historyRecordRoutes);
|
||||
app.use('/api/words', wordRoutes);
|
||||
|
||||
app.use(express.static(config.publicDir, { extensions: ['html'] }));
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,145 @@
|
|||
import { Router } from 'express';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||
|
||||
// Common words used to build a player-friendly Wordle answer pool.
|
||||
// These are well-known 5-letter English words that make fair Wordle puzzles.
|
||||
const COMMON_WORDS = new Set([
|
||||
'ABOUT','ABOVE','ABUSE','ACTOR','ACUTE','ADMIT','ADOPT','ADULT','AFTER',
|
||||
'AGAIN','AGENT','AGREE','AHEAD','ALARM','ALBUM','ALERT','ALIGN','ALIVE',
|
||||
'ALLEY','ALLOW','ALONE','ALONG','ALTER','ANGEL','ANGLE','ANGRY','ANKLE',
|
||||
'ANNEX','ANNOY','APART','APPLE','APPLY','APRIL','ARENA','ARGUE','ARISE',
|
||||
'ARMOR','ARRAY','ARROW','ASIDE','ASKED','ASSET','AVOID','AWARD','AWARE',
|
||||
'AWFUL','BADLY','BAKER','BASIC','BASIS','BATCH','BEACH','BEGAN','BEGIN',
|
||||
'BEING','BELOW','BENCH','BIBLE','BIRTH','BLACK','BLADE','BLAME','BLAND',
|
||||
'BLANK','BLAST','BLAZE','BLEED','BLEND','BLESS','BLIND','BLOCK','BLOOD',
|
||||
'BLOWN','BOARD','BONUS','BOOST','BOUND','BRAIN','BRAND','BRAVE','BREAD',
|
||||
'BREAK','BREED','BRICK','BRIDE','BRIEF','BRING','BROAD','BROKE','BROOK',
|
||||
'BROWN','BRUSH','BUILD','BUILT','BUNCH','BURNT','BUYER','CACHE','CANDY',
|
||||
'CARGO','CARRY','CATCH','CAUSE','CHAIN','CHAIR','CHAOS','CHARM','CHASE',
|
||||
'CHEAP','CHECK','CHEEK','CHEER','CHESS','CHEST','CHIEF','CHILD','CHINA',
|
||||
'CIVIC','CIVIL','CLAIM','CLASS','CLEAN','CLEAR','CLERK','CLICK','CLIFF',
|
||||
'CLIMB','CLING','CLOCK','CLOSE','CLOUD','COACH','COAST','COULD','COUNT',
|
||||
'COURT','COVER','CRACK','CRAFT','CRASH','CRAZY','CREAM','CREEK','CRIME',
|
||||
'CRISP','CROSS','CROWD','CROWN','CRUEL','CRUSH','CURVE','CYCLE','DAILY',
|
||||
'DANCE','DEATH','DEBUT','DELAY','DENSE','DEPOT','DEPTH','DERBY','DEVIL',
|
||||
'DIGIT','DIRTY','DOING','DOUBT','DOUGH','DRAFT','DRAIN','DRAMA','DRANK',
|
||||
'DRAWN','DREAM','DRESS','DRIED','DRIFT','DRILL','DRINK','DRIVE','DROVE',
|
||||
'DRUGS','DRUMS','DRUNK','DWELL','DYING','EAGER','EARLY','EARTH','EIGHT',
|
||||
'ELECT','EMAIL','EMPTY','ENEMY','ENJOY','ENTER','ENTRY','EQUAL','ERROR',
|
||||
'ESSAY','EVERY','EVENT','EXACT','EXIST','EXTRA','FAINT','FAIRY','FAITH',
|
||||
'FALSE','FANCY','FAULT','FEAST','FENCE','FETCH','FEVER','FEWER','FIELD',
|
||||
'FIFTH','FIFTY','FIGHT','FINAL','FLAIR','FLAME','FLASH','FLEET','FLESH',
|
||||
'FLOAT','FLOOD','FLOOR','FLOUR','FLUID','FOCUS','FORCE','FORGE','FORUM',
|
||||
'FOUND','FRANK','FRAUD','FRESH','FRONT','FROST','FROZE','FRUIT','FULLY',
|
||||
'FUNDS','FUNNY','GHOST','GIANT','GIVEN','GLASS','GLOBE','GLOOM','GLOSS',
|
||||
'GLOVE','GOING','GRACE','GRADE','GRAIN','GRAND','GRANT','GRASP','GRASS',
|
||||
'GRAVE','GREAT','GREEN','GREET','GRIEF','GRIND','GROAN','GROSS','GROUP',
|
||||
'GROVE','GROWN','GUARD','GUESS','GUEST','GUIDE','GUILT','GUISE','GUSTO',
|
||||
'HABIT','HAPPY','HARSH','HEART','HEAVY','HENCE','HINGE','HONEY','HONOR',
|
||||
'HORSE','HOTEL','HOUSE','HUMAN','HUMOR','HURRY','IDEAL','IMAGE','IMPLY',
|
||||
'INBOX','INDEX','INNER','INPUT','INTER','INTRO','ISSUE','JAPAN','JOINT',
|
||||
'JOUST','JUDGE','JUICE','JUICY','JUMBO','KARMA','KNIFE','KNOCK','KNOWN',
|
||||
'LABEL','LARGE','LASER','LATER','LAUGH','LAYER','LEARN','LEASE','LEAVE',
|
||||
'LEGAL','LEVEL','LIGHT','LIMIT','LINEN','LIVER','LOCAL','LODGE','LOGIC',
|
||||
'LOOSE','LOVER','LOWER','LUCKY','LUNAR','LYING','MAGIC','MAJOR','MAKER',
|
||||
'MARCH','MATCH','MAYOR','MEDIA','MERIT','METAL','MIGHT','MINOR','MINUS',
|
||||
'MIXED','MODEL','MONEY','MONTH','MORAL','MOTOR','MOUNT','MOUSE','MOUTH',
|
||||
'MOVED','MOVIE','MUSIC','NAIVE','NEVER','NIGHT','NOISE','NORTH','NOTED',
|
||||
'NOVEL','NURSE','OCCUR','OFFER','OFTEN','ONSET','OPERA','ORBIT','ORDER',
|
||||
'OTHER','OUTER','OUNCE','OWNER','PAINT','PANEL','PAPER','PARTY','PASTA',
|
||||
'PATCH','PAUSE','PEACE','PEARL','PEDAL','PENNY','PERCH','PHASE','PHONE',
|
||||
'PHOTO','PIANO','PIECE','PILOT','PITCH','PIXEL','PIZZA','PLACE','PLAIN',
|
||||
'PLANE','PLANT','PLATE','PLAZA','PLEAD','PLUCK','PLUMB','PLUME','PLUMP',
|
||||
'PLUNGE','POINT','POKER','POLAR','POUND','POWER','PRESS','PRICE','PRIDE',
|
||||
'PRIME','PRINT','PRIOR','PRIZE','PROBE','PROOF','PROSE','PROUD','PROVE',
|
||||
'PROXY','PULSE','PUPIL','QUEEN','QUERY','QUEST','QUEUE','QUICK','QUIET',
|
||||
'QUITE','QUOTA','QUOTE','RADAR','RADIO','RAISE','RALLY','RANCH','RANGE',
|
||||
'RAPID','RATIO','REACH','READY','REALM','REBEL','REFER','REIGN','RELAX',
|
||||
'REPLY','RIGHT','RIGID','RISKY','RIVAL','RIVER','ROBOT','ROCKY','ROMAN',
|
||||
'ROUGH','ROUND','ROUTE','ROYAL','RULER','RUMOR','RURAL','SAINT','SALAD',
|
||||
'SAUCE','SCALE','SCALD','SCENE','SCOUT','SCREW','SEIZE','SENSE','SERVE',
|
||||
'SEVEN','SHADE','SHAKE','SHALL','SHAME','SHAPE','SHARE','SHARP','SHELF',
|
||||
'SHIFT','SHIRT','SHOCK','SHOOT','SHORT','SHOUT','SIGHT','SINCE','SIXTH',
|
||||
'SIXTY','SIZED','SKILL','SLEEP','SLICE','SLIDE','SLOPE','SLUMP','SMALL',
|
||||
'SMART','SMELL','SMILE','SMOKE','SOLAR','SOLID','SOLVE','SORRY','SOUND',
|
||||
'SOUTH','SPACE','SPARK','SPEAK','SPELL','SPEND','SPICE','SPILL','SPINE',
|
||||
'SPLIT','SPOKE','SPORT','SPRAY','SQUAD','STACK','STAFF','STAGE','STAIN',
|
||||
'STAKE','STALE','STALL','STAND','START','STATE','STEAL','STEEL','STEEP',
|
||||
'STICK','STILL','STOCK','STOOD','STORE','STORM','STORY','STRAP','STRAW',
|
||||
'STRIP','STUDY','STUFF','STYLE','SUGAR','SUITE','SUPER','SURGE','SWAMP',
|
||||
'SWEAR','SWEAT','SWEEP','SWEET','SWEPT','SWIFT','SWING','SWORE','SWORN',
|
||||
'TABLE','TAKEN','TASTE','TEACH','TEETH','TENSE','TERMS','THANK','THEIR',
|
||||
'THEME','THERE','THESE','THICK','THING','THINK','THIRD','THOSE','THREE',
|
||||
'THREW','THROW','TIGHT','TIMER','TIRED','TITLE','TODAY','TOKEN','TONAL',
|
||||
'TOPIC','TOTAL','TOUCH','TOUGH','TRACE','TRACK','TRADE','TRAIL','TRAIN',
|
||||
'TRAIT','TRASH','TREAT','TREND','TRIAL','TRIBE','TRICK','TRIED','TROOP',
|
||||
'TRUCK','TRULY','TRUMP','TRUNK','TRUTH','TUMOR','TWICE','TWIST','TYPICAL',
|
||||
'ULTRA','UNDER','UNION','UNITE','UNITY','UNTIL','UPPER','UPSET','URBAN',
|
||||
'USAGE','UTTER','VALID','VALUE','VALVE','VERSE','VIDEO','VIGOR','VIRAL',
|
||||
'VIRUS','VISIT','VITAL','VIVID','VOICE','VOTER','WAIST','WASTE','WATCH',
|
||||
'WATER','WEARY','WEIGH','WEIRD','WHILE','WHITE','WHOLE','WHOSE','WIDER',
|
||||
'WITCH','WOMAN','WOMEN','WORLD','WORRY','WORSE','WORST','WORTH','WOULD',
|
||||
'WOUND','WRIST','WRITE','WROTE','YOUNG','YOURS','YOUTH','ZONAL',
|
||||
]);
|
||||
|
||||
// ── Load word sets at startup ─────────────────────────────────────────────────
|
||||
|
||||
let allFiveLetterWords = null; // Set<string> — all valid 5-letter ENABLE words
|
||||
let answerPool = null; // string[] — shuffleable answer list
|
||||
|
||||
function loadWordLists() {
|
||||
if (allFiveLetterWords) return;
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(WORDLIST_PATH, 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[words] ENABLE word list not found, using fallback.');
|
||||
raw = [...COMMON_WORDS].join('\n');
|
||||
}
|
||||
|
||||
const enableFive = new Set(
|
||||
raw.split('\n')
|
||||
.map(w => w.trim().toUpperCase())
|
||||
.filter(w => w.length === 5 && /^[A-Z]{5}$/.test(w)),
|
||||
);
|
||||
|
||||
allFiveLetterWords = enableFive;
|
||||
|
||||
// Answer pool: prefer curated common words that are also in ENABLE;
|
||||
// supplement with additional ENABLE words up to a healthy pool size.
|
||||
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
|
||||
answerPool = curated.length >= 200 ? curated : [...enableFive];
|
||||
|
||||
console.log(`[words] loaded ${enableFive.size} valid 5-letter words, ${answerPool.length} answer candidates`);
|
||||
}
|
||||
|
||||
loadWordLists();
|
||||
|
||||
// ── Router ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/words/wordle/start
|
||||
// Returns a random answer word plus the full valid-word pool for the client.
|
||||
router.get('/wordle/start', (_req, res) => {
|
||||
const answer = answerPool[Math.floor(Math.random() * answerPool.length)];
|
||||
res.json({ answer, validWords: [...allFiveLetterWords] });
|
||||
});
|
||||
|
||||
// POST /api/words/wordle/validate { word: string }
|
||||
// Quick single-word validation (used as a lightweight alternative to the full pool).
|
||||
router.post('/wordle/validate', (req, res) => {
|
||||
const word = (req.body?.word ?? '').trim().toUpperCase();
|
||||
if (!/^[A-Z]{5}$/.test(word)) {
|
||||
return res.json({ valid: false });
|
||||
}
|
||||
res.json({ valid: allFiveLetterWords.has(word) });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Loading…
Reference in New Issue