feat: add Ghost word game with server-side AI and skill profiles
- Implement client and server logic for Ghost game (`GhostGame`, `GhostLogic`, `GhostAI`, `ghostEngine`). - Add perfect-play search and 5-tier skill system to the server-side dictionary engine. - Register Ghost in the game registry and route it through `GameRoomScene`. - Add `playIntro` option to `Portrait.js` and `skipIntro` to Wordle to control intro speeches on round restarts. - Improve Scrabble rack reordering layout and gap handling during drag-and-drop. - Expose Ghost API endpoints (`/ghost/judge`, `/ghost/ai-move`) in `wordRoutes.js`.
This commit is contained in:
parent
dd384d6c15
commit
371724dc84
|
|
@ -0,0 +1,40 @@
|
|||
// Thin client wrapper around the server Ghost solver. The trie + perfect-play
|
||||
// search live in server/words/ghostEngine.js; here we just ship the fragment and
|
||||
// pace the AI so turns feel deliberate.
|
||||
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
// Ask the server for the AI's next letter. Resolves to:
|
||||
// { letter, isWord, isPrefix } (letter is null when the AI is at a dead end)
|
||||
export async function requestAILetter({ fragment, skill }) {
|
||||
try {
|
||||
return await api.post('/words/ghost/ai-move', { fragment, skill });
|
||||
} catch (err) {
|
||||
console.error('[ghost] ai-move request failed:', err);
|
||||
return { letter: null, isWord: false, isPrefix: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Grade a fragment a player just formed: { isWord, isPrefix }.
|
||||
export async function judgeFragment(fragment) {
|
||||
try {
|
||||
return await api.post('/words/ghost/judge', { fragment });
|
||||
} catch (err) {
|
||||
console.error('[ghost] judge request failed:', err);
|
||||
return { isWord: false, isPrefix: true }; // fail open: treat as a safe continuation
|
||||
}
|
||||
}
|
||||
|
||||
// "Thinking" pause before the AI commits, in ms. Stronger players act faster.
|
||||
const THINK_DELAY = {
|
||||
1: [1600, 2600],
|
||||
2: [1300, 2200],
|
||||
3: [1000, 1800],
|
||||
4: [800, 1400],
|
||||
5: [550, 1100],
|
||||
};
|
||||
|
||||
export function nextThinkDelay(skill) {
|
||||
const [min, max] = THINK_DELAY[skill] ?? THINK_DELAY[3];
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
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 {
|
||||
GHOST_LETTERS, createInitialState, appendLetter, concedeRound, other,
|
||||
} from './GhostLogic.js';
|
||||
import { requestAILetter, judgeFragment, nextThinkDelay } from './GhostAI.js';
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const TILE_W = 84;
|
||||
const TILE_H = 96;
|
||||
const GAP = 10;
|
||||
const ROW_Y = 440;
|
||||
|
||||
const PLR_X = 200;
|
||||
const AI_X = GAME_WIDTH - 200;
|
||||
const PORT_Y = 300;
|
||||
const PORT_R = 78;
|
||||
|
||||
const KEY_W = 64;
|
||||
const KEY_H = 74;
|
||||
const KEY_G = 8;
|
||||
const KBD_Y = 660;
|
||||
|
||||
const DEPTH = { bg: 0, tile: 2, ui: 10 };
|
||||
const VD = DEPTH.ui + 20;
|
||||
|
||||
const C = {
|
||||
tileBg: 0x1a1a1b,
|
||||
border: 0x565758,
|
||||
player: 0x538d4e, // green — letters you played
|
||||
ai: 0xb59f3b, // gold — letters the AI played
|
||||
key: 0x818384,
|
||||
keyDim: 0x3a3a3c,
|
||||
};
|
||||
|
||||
const KEYBOARD_ROWS = [
|
||||
['Q','W','E','R','T','Y','U','I','O','P'],
|
||||
['A','S','D','F','G','H','J','K','L'],
|
||||
['Z','X','C','V','B','N','M'],
|
||||
];
|
||||
|
||||
const TARGET = GHOST_LETTERS.length; // 5 — spelling GHOST loses the match
|
||||
|
||||
// ── Scene ──────────────────────────────────────────────────────────────────────
|
||||
export default class GhostGame extends Phaser.Scene {
|
||||
constructor() { super('GhostGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
this.opponent = data.opponents?.[0] ?? null;
|
||||
this.skill = this.opponent?.skill ?? 3;
|
||||
this.playerGhost = data.playerGhost ?? 0; // letters earned (losses)
|
||||
this.aiGhost = data.aiGhost ?? 0;
|
||||
this.startingPlayer = data.startingPlayer ?? 'player';
|
||||
this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts
|
||||
|
||||
this.gs = null;
|
||||
this.tileObjs = [];
|
||||
this.busy = false; // an async judge / AI turn is in flight
|
||||
this.roundEnded = false;
|
||||
this.aiTimer = null;
|
||||
this.opponentPortrait = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildParticleTexture();
|
||||
|
||||
this.gs = createInitialState({ startingPlayer: this.startingPlayer });
|
||||
|
||||
this.buildBackground();
|
||||
this.buildPortraits();
|
||||
this.buildMeters();
|
||||
this.buildKeyboard();
|
||||
this.buildTexts();
|
||||
this.buildControls();
|
||||
this.setupInput();
|
||||
|
||||
if (this.gs.turn === 'ai') this.scheduleAITurn();
|
||||
else this.setTurnIndicator('player');
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
buildParticleTexture() {
|
||||
if (this.textures.exists('ghostParticle')) return;
|
||||
const g = this.make.graphics({ add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('ghostParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildBackground() {
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg)
|
||||
.setDepth(DEPTH.bg);
|
||||
this.add.text(GAME_WIDTH / 2, 70, 'GHOST', {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
this.add.text(GAME_WIDTH / 2, 116, 'add a letter — don’t finish a word', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
createPlayerPortrait(this, PLR_X, PORT_Y, PORT_R, DEPTH.ui, 'GhostGame');
|
||||
this.add.text(PLR_X, PORT_Y + PORT_R + 12, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 1);
|
||||
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, AI_X, PORT_Y, PORT_R, DEPTH.ui, { playIntro: !this.skipIntro });
|
||||
this.add.text(AI_X, PORT_Y + PORT_R + 12, this.opponent?.name ?? 'CPU', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 1);
|
||||
}
|
||||
|
||||
buildMeters() {
|
||||
const my = PORT_Y + PORT_R + 56;
|
||||
this.playerMeter = this.createMeter(PLR_X, my);
|
||||
this.aiMeter = this.createMeter(AI_X, my);
|
||||
this.updateMeter(this.playerMeter, this.playerGhost);
|
||||
this.updateMeter(this.aiMeter, this.aiGhost);
|
||||
}
|
||||
|
||||
createMeter(cx, y) {
|
||||
const SP = 40;
|
||||
const startX = cx - (TARGET - 1) * SP / 2;
|
||||
return GHOST_LETTERS.map((ch, i) =>
|
||||
this.add.text(startX + i * SP, y, ch, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui).setAlpha(0.25),
|
||||
);
|
||||
}
|
||||
|
||||
updateMeter(meter, count) {
|
||||
meter.forEach((t, i) => {
|
||||
const lit = i < count;
|
||||
t.setColor(lit ? COLORS.dangerHex : COLORS.mutedHex);
|
||||
t.setAlpha(lit ? 1 : 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
buildKeyboard() {
|
||||
this.keyObjs = {};
|
||||
KEYBOARD_ROWS.forEach((keys, rowIdx) => {
|
||||
const rowW = keys.length * (KEY_W + KEY_G) - KEY_G;
|
||||
let x = GAME_WIDTH / 2 - rowW / 2;
|
||||
keys.forEach(key => {
|
||||
const cx = x + KEY_W / 2;
|
||||
const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2;
|
||||
x += KEY_W + KEY_G;
|
||||
|
||||
const container = this.add.container(cx, cy).setDepth(DEPTH.ui);
|
||||
const bg = this.add.rectangle(0, 0, KEY_W, KEY_H, C.key);
|
||||
bg.setInteractive({ cursor: 'pointer', useHandCursor: true });
|
||||
const lbl = this.add.text(0, 0, key, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, lbl]);
|
||||
this.keyObjs[key] = { container, bg, lbl };
|
||||
|
||||
bg.on('pointerdown', () => this.playLetter(key));
|
||||
bg.on('pointerover', () => { if (this.canPlay()) bg.setFillStyle(0x9a9b9c); });
|
||||
bg.on('pointerout', () => bg.setFillStyle(this.canPlay() ? C.key : C.keyDim));
|
||||
});
|
||||
});
|
||||
this.refreshKeyboardEnabled();
|
||||
}
|
||||
|
||||
buildTexts() {
|
||||
this.turnText = this.add.text(GAME_WIDTH / 2, 210, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
this.statusText = this.add.text(GAME_WIDTH / 2, ROW_Y + TILE_H / 2 + 60, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).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 (/^[a-zA-Z]$/.test(evt.key)) this.playLetter(evt.key.toUpperCase());
|
||||
});
|
||||
}
|
||||
|
||||
canPlay() {
|
||||
return !this.busy && !this.roundEnded && this.gs?.status === 'playing' && this.gs.turn === 'player';
|
||||
}
|
||||
|
||||
refreshKeyboardEnabled() {
|
||||
const on = this.canPlay();
|
||||
for (const key in this.keyObjs) {
|
||||
this.keyObjs[key].bg.setFillStyle(on ? C.key : C.keyDim);
|
||||
}
|
||||
}
|
||||
|
||||
setTurnIndicator(who) {
|
||||
if (who === 'player') {
|
||||
this.turnText.setText('Your turn');
|
||||
this.statusText.setText('');
|
||||
} else {
|
||||
this.turnText.setText(`${this.opponent?.name ?? 'CPU'} is thinking…`);
|
||||
}
|
||||
this.refreshKeyboardEnabled();
|
||||
}
|
||||
|
||||
// ── Turn flow ────────────────────────────────────────────────────────────────
|
||||
|
||||
async playLetter(letter) {
|
||||
if (!this.canPlay()) return;
|
||||
this.busy = true;
|
||||
this.refreshKeyboardEnabled();
|
||||
|
||||
this.gs = appendLetter(this.gs, letter, 'player');
|
||||
this.addTile(letter, 'player');
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
|
||||
const { isWord, isPrefix } = await judgeFragment(this.gs.fragment);
|
||||
if (!this.scene.isActive()) return;
|
||||
|
||||
if (isWord) return this.endRound('player', 'word');
|
||||
if (!isPrefix) return this.endRound('player', 'off-dictionary');
|
||||
|
||||
this.busy = false;
|
||||
this.scheduleAITurn();
|
||||
}
|
||||
|
||||
scheduleAITurn() {
|
||||
if (this.roundEnded) return;
|
||||
this.busy = true;
|
||||
this.setTurnIndicator('ai');
|
||||
this.aiTimer = this.time.delayedCall(nextThinkDelay(this.skill), () => this.doAITurn());
|
||||
}
|
||||
|
||||
async doAITurn() {
|
||||
if (this.roundEnded) return;
|
||||
|
||||
const { letter, isWord, isPrefix } = await requestAILetter({ fragment: this.gs.fragment, skill: this.skill });
|
||||
if (!this.scene.isActive()) return;
|
||||
|
||||
if (letter == null) return this.endRound('ai', 'stumped');
|
||||
|
||||
this.gs = appendLetter(this.gs, letter, 'ai');
|
||||
this.addTile(letter, 'ai');
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
if (isWord) return this.endRound('ai', 'word');
|
||||
if (!isPrefix) return this.endRound('ai', 'off-dictionary');
|
||||
|
||||
this.busy = false;
|
||||
this.setTurnIndicator('player');
|
||||
}
|
||||
|
||||
// ── Fragment tiles ───────────────────────────────────────────────────────────
|
||||
|
||||
addTile(letter, who) {
|
||||
const container = this.add.container(0, ROW_Y).setDepth(DEPTH.tile);
|
||||
const bg = this.add.rectangle(0, 0, TILE_W, TILE_H, who === 'player' ? C.player : C.ai)
|
||||
.setStrokeStyle(2, C.border);
|
||||
const label = this.add.text(0, 0, letter, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5);
|
||||
container.add([bg, label]);
|
||||
this.tileObjs.push({ container });
|
||||
this.layoutFragment();
|
||||
|
||||
container.setScale(0);
|
||||
this.tweens.add({ targets: container, scale: 1, duration: 170, ease: 'Back.easeOut' });
|
||||
}
|
||||
|
||||
layoutFragment() {
|
||||
const n = this.tileObjs.length;
|
||||
const totalW = n * TILE_W + (n - 1) * GAP;
|
||||
const startX = GAME_WIDTH / 2 - totalW / 2 + TILE_W / 2;
|
||||
this.tileObjs.forEach((t, i) => { t.container.x = startX + i * (TILE_W + GAP); });
|
||||
}
|
||||
|
||||
shakeFragment() {
|
||||
const containers = this.tileObjs.map(t => t.container);
|
||||
const startXs = containers.map(c => c.x);
|
||||
this.tweens.add({
|
||||
targets: containers, x: (_t, _k, _v, i) => startXs[i] + 8,
|
||||
duration: 50, yoyo: true, repeat: 3, ease: 'Sine.easeInOut',
|
||||
onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Round end → G-H-O-S-T scoring ─────────────────────────────────────────────
|
||||
|
||||
endRound(loser, reason) {
|
||||
if (this.roundEnded) return;
|
||||
this.roundEnded = true;
|
||||
this.busy = true;
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
this.gs = concedeRound(this.gs, loser, reason);
|
||||
this.refreshKeyboardEnabled();
|
||||
this.shakeFragment();
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
if (loser === 'player') this.playerGhost++;
|
||||
else this.aiGhost++;
|
||||
|
||||
const meter = loser === 'player' ? this.playerMeter : this.aiMeter;
|
||||
const count = loser === 'player' ? this.playerGhost : this.aiGhost;
|
||||
this.updateMeter(meter, count);
|
||||
const lit = meter[count - 1];
|
||||
if (lit) this.tweens.add({ targets: lit, scale: 1.6, duration: 180, yoyo: true, ease: 'Back.easeOut' });
|
||||
|
||||
this.opponentPortrait?.playEmotion(loser === 'player' ? 'happy' : 'upset');
|
||||
|
||||
const matchOver = this.playerGhost >= TARGET || this.aiGhost >= TARGET;
|
||||
if (matchOver) this.recordResult(this.aiGhost >= TARGET ? 'win' : 'loss');
|
||||
|
||||
this.time.delayedCall(800, () => {
|
||||
if (matchOver) this.showVictoryScreen(this.aiGhost >= TARGET);
|
||||
else this.showRoundResult(loser, reason);
|
||||
});
|
||||
}
|
||||
|
||||
ghostStr(count) {
|
||||
return GHOST_LETTERS.map((l, i) => (i < count ? l : '·')).join(' ');
|
||||
}
|
||||
|
||||
showRoundResult(loser, reason) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const oppName = this.opponent?.name ?? 'CPU';
|
||||
const loserName = loser === 'player' ? 'You' : oppName;
|
||||
const verb = loser === 'player' ? 'earn' : 'earns';
|
||||
|
||||
let headline, detail;
|
||||
if (reason === 'word') {
|
||||
headline = `${loserName} completed a word`;
|
||||
detail = `“${this.gs.fragment}” is a word`;
|
||||
} else if (reason === 'off-dictionary') {
|
||||
headline = `${loserName} ran off the dictionary`;
|
||||
detail = `“${this.gs.fragment}” starts no valid word`;
|
||||
} else {
|
||||
headline = `${loserName} got stuck`;
|
||||
detail = `nothing safe follows “${this.gs.fragment}”`;
|
||||
}
|
||||
|
||||
const earnedLetter = GHOST_LETTERS[(loser === 'player' ? this.playerGhost : this.aiGhost) - 1];
|
||||
const earnLine = `${loserName} ${verb} a “${earnedLetter}”`;
|
||||
const seriesLine = `You ${this.ghostStr(this.playerGhost)} ${oppName} ${this.ghostStr(this.aiGhost)}`;
|
||||
|
||||
const panel = this.add.rectangle(cx, cy, 720, 240, 0x0a0e14, 0.94)
|
||||
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10);
|
||||
const t1 = this.add.text(cx, cy - 70, headline, {
|
||||
fontFamily: 'Righteous', fontSize: '36px',
|
||||
color: loser === 'player' ? COLORS.dangerHex : '#6aff88',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t2 = this.add.text(cx, cy - 18, detail, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t3 = this.add.text(cx, cy + 22, earnLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
const t4 = this.add.text(cx, cy + 64, seriesLine, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 11);
|
||||
|
||||
this.time.delayedCall(2600, () => {
|
||||
[panel, t1, t2, t3, t4].forEach(o => o.destroy());
|
||||
this.startNextRound();
|
||||
});
|
||||
}
|
||||
|
||||
startNextRound() {
|
||||
this.scene.restart({
|
||||
...this._initData,
|
||||
playerGhost: this.playerGhost,
|
||||
aiGhost: this.aiGhost,
|
||||
startingPlayer: other(this.startingPlayer),
|
||||
skipIntro: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Victory ────────────────────────────────────────────────────────────────
|
||||
|
||||
showVictoryScreen(playerWon) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const PW = 840, PH = 560;
|
||||
const top = cy - PH / 2, bot = cy + PH / 2;
|
||||
|
||||
const fw = this.add.particles(cx, cy, 'ghostParticle', {
|
||||
speed: { min: 80, max: 480 }, lifespan: 1400,
|
||||
scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
|
||||
quantity: 3, frequency: 35, angle: { min: 0, max: 360 },
|
||||
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44],
|
||||
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, () => { fw.stop(); this.time.delayedCall(1400, () => fw.destroy()); });
|
||||
|
||||
this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(VD);
|
||||
this.add.text(cx, top + 62, playerWon ? 'You Win!' : `${this.opponent?.name ?? 'CPU'} Wins!`, {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: playerWon ? '#ffd700' : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(VD + 1);
|
||||
|
||||
const WIN_R = 92, LOSE_R = 64;
|
||||
const portY = top + 215;
|
||||
const plrX = cx - 195, oppX = cx + 195;
|
||||
|
||||
const plrPortrait = createPlayerPortrait(this, plrX, portY, playerWon ? WIN_R : LOSE_R, VD + 2, 'GhostGame');
|
||||
this.add.text(plrX, portY + (playerWon ? WIN_R : LOSE_R) + 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);
|
||||
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, playerWon ? LOSE_R : WIN_R, VD + 2, { playIntro: false });
|
||||
this.add.text(oppX, portY + (playerWon ? LOSE_R : WIN_R) + 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');
|
||||
|
||||
this.add.text(cx, portY + WIN_R + 64,
|
||||
`You ${this.ghostStr(this.playerGhost)} ${this.opponent?.name ?? 'CPU'} ${this.ghostStr(this.aiGhost)}`, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(VD + 1);
|
||||
|
||||
const btnsY = bot - 64;
|
||||
new Button(this, cx - 130, btnsY, 'Play Again', () => {
|
||||
this.scene.restart({ ...this._initData, playerGhost: 0, aiGhost: 0, startingPlayer: 'player', skipIntro: false });
|
||||
}, { 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'ghost',
|
||||
score: result === 'win' ? 100 : 0,
|
||||
opponentScores: [0],
|
||||
result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; }
|
||||
this.opponentPortrait?.destroy();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Pure Ghost logic — no Phaser, no network. Word judging (did a letter complete a
|
||||
// word / go off-dictionary) is decided by the server, then recorded here via
|
||||
// concedeRound; this module only tracks the fragment, whose turn it is, and the
|
||||
// running G-H-O-S-T score.
|
||||
|
||||
export const MIN_LEN = 4;
|
||||
export const GHOST_LETTERS = ['G', 'H', 'O', 'S', 'T'];
|
||||
|
||||
export function other(who) {
|
||||
return who === 'player' ? 'ai' : 'player';
|
||||
}
|
||||
|
||||
export function createInitialState({ startingPlayer = 'player' } = {}) {
|
||||
return {
|
||||
fragment: '',
|
||||
turn: startingPlayer, // 'player' | 'ai'
|
||||
history: [], // [{ who, letter }]
|
||||
status: 'playing', // 'playing' | 'over'
|
||||
loser: null, // 'player' | 'ai'
|
||||
reason: null, // 'word' | 'off-dictionary' | 'stumped'
|
||||
};
|
||||
}
|
||||
|
||||
// Append a letter played by `who`, then hand the turn to the other player.
|
||||
export function appendLetter(state, letter, who) {
|
||||
if (state.status !== 'playing') return state;
|
||||
const L = letter.toUpperCase();
|
||||
return {
|
||||
...state,
|
||||
fragment: state.fragment + L,
|
||||
history: [...state.history, { who, letter: L }],
|
||||
turn: other(who),
|
||||
};
|
||||
}
|
||||
|
||||
// End the round with a loser and a reason.
|
||||
export function concedeRound(state, loser, reason) {
|
||||
return { ...state, status: 'over', loser, reason };
|
||||
}
|
||||
|
||||
export function isRoundOver(state) {
|
||||
return state.status === 'over';
|
||||
}
|
||||
|
|
@ -293,14 +293,20 @@ export default class ScrabbleGame extends Phaser.Scene {
|
|||
|
||||
// ── Rack (human) ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Shared tray geometry: x of slot 0's center, and center-to-center slot pitch.
|
||||
rackLayout() {
|
||||
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
|
||||
return { startX: GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2, slot: RACK_TILE + RACK_GAP };
|
||||
}
|
||||
|
||||
renderRack() {
|
||||
for (const o of this.rackObjs) o.destroy();
|
||||
this.rackObjs = [];
|
||||
this._dragInsertIndex = null;
|
||||
const rack = this.players[0].rack;
|
||||
const w = RACK_SIZE * RACK_TILE + (RACK_SIZE - 1) * RACK_GAP;
|
||||
const startX = GAME_WIDTH / 2 - w / 2 + RACK_TILE / 2;
|
||||
const { startX, slot } = this.rackLayout();
|
||||
rack.forEach((token, i) => {
|
||||
const x = startX + i * (RACK_TILE + RACK_GAP);
|
||||
const x = startX + i * slot;
|
||||
const letter = token === BLANK ? '' : token;
|
||||
const value = token === BLANK ? 0 : (LETTER_VALUES[token] ?? 0);
|
||||
const tile = this.makeTile(RACK_TILE, letter, value);
|
||||
|
|
@ -326,14 +332,28 @@ export default class ScrabbleGame extends Phaser.Scene {
|
|||
if (!this.canHumanAct() || this.exchangeMode || obj._rackIndex === undefined) return;
|
||||
obj.setDepth(DEPTH.drag);
|
||||
obj._dragging = true;
|
||||
obj._fromIndex = obj._rackIndex;
|
||||
this._dragInsertIndex = obj._fromIndex;
|
||||
});
|
||||
this.input.on('drag', (_p, obj, dx, dy) => {
|
||||
if (!obj._dragging) return;
|
||||
obj.setPosition(dx, dy);
|
||||
const sq = this.squareAt(obj.x, obj.y);
|
||||
if (sq && this.squareFree(sq.row, sq.col)) {
|
||||
// Pointer is over a free board square — close any tray gap we opened.
|
||||
this.clearRackGap(obj);
|
||||
} else if (this.inRackZone(obj.y)) {
|
||||
// Tray-reorder mode — slide neighbours aside to reveal the drop slot.
|
||||
this.updateRackGap(obj, this.rackInsertIndexAt(obj.x));
|
||||
} else {
|
||||
// Dead zone (e.g. over an occupied square) — no gap.
|
||||
this.clearRackGap(obj);
|
||||
}
|
||||
});
|
||||
this.input.on('dragend', (_p, obj) => {
|
||||
if (!obj._dragging) return;
|
||||
obj._dragging = false;
|
||||
const insert = this._dragInsertIndex;
|
||||
const sq = this.squareAt(obj.x, obj.y);
|
||||
if (sq && this.squareFree(sq.row, sq.col)) {
|
||||
// Defer: placeTentative rebuilds the rack and destroys this very tile,
|
||||
|
|
@ -341,13 +361,73 @@ export default class ScrabbleGame extends Phaser.Scene {
|
|||
obj.setVisible(false);
|
||||
const token = obj._token, row = sq.row, col = sq.col;
|
||||
this.time.delayedCall(0, () => this.placeTentative(token, row, col));
|
||||
} else if (this.inRackZone(obj.y) && insert !== null && insert !== obj._fromIndex) {
|
||||
this.reorderRack(obj._fromIndex, insert);
|
||||
} else {
|
||||
// No move — snap the dragged tile home and close the gap.
|
||||
obj.setDepth(DEPTH.tile);
|
||||
this.clearRackGap(obj);
|
||||
this.tweens.add({ targets: obj, x: obj._homeX, y: obj._homeY, duration: 140, ease: 'Back.easeOut' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// True when y is below the board, i.e. in the tray region (not over the grid).
|
||||
inRackZone(y) {
|
||||
return y > BOARD_Y0 + BOARD_PX;
|
||||
}
|
||||
|
||||
// Which rack slot would the dragged tile land on, given its current x.
|
||||
rackInsertIndexAt(x) {
|
||||
const { startX, slot } = this.rackLayout();
|
||||
const max = this.players[0].rack.length - 1;
|
||||
const i = Math.round((x - startX) / slot);
|
||||
return Math.max(0, Math.min(max, i));
|
||||
}
|
||||
|
||||
// Lay out every non-dragged tile, leaving slot `insertIndex` empty for the drop.
|
||||
updateRackGap(draggedObj, insertIndex) {
|
||||
this._dragInsertIndex = insertIndex;
|
||||
const { startX, slot } = this.rackLayout();
|
||||
// Tiles in their natural rack order, excluding the one being dragged.
|
||||
const others = this.rackObjs.filter(t => t !== draggedObj);
|
||||
let s = 0;
|
||||
for (const tile of others) {
|
||||
if (s === insertIndex) s++; // reserve the gap slot
|
||||
const tx = startX + s * slot;
|
||||
if (tile._homeX !== tx) { // only retween when the target changes
|
||||
tile._homeX = tx;
|
||||
this.tweens.add({ targets: tile, x: tx, duration: 120, ease: 'Cubic.easeOut' });
|
||||
}
|
||||
s++;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore all non-dragged tiles to their natural slots (gap closed).
|
||||
clearRackGap(draggedObj) {
|
||||
if (this._dragInsertIndex === null) return;
|
||||
this._dragInsertIndex = null;
|
||||
const { startX, slot } = this.rackLayout();
|
||||
for (const tile of this.rackObjs) {
|
||||
if (tile === draggedObj) continue;
|
||||
const tx = startX + tile._rackIndex * slot;
|
||||
if (tile._homeX !== tx) {
|
||||
tile._homeX = tx;
|
||||
this.tweens.add({ targets: tile, x: tx, duration: 120, ease: 'Cubic.easeOut' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reorderRack(fromIndex, toIndex) {
|
||||
const rack = this.players[0].rack;
|
||||
const [tok] = rack.splice(fromIndex, 1);
|
||||
// `toIndex` is the desired final-array slot for the tile; updateRackGap laid the
|
||||
// remaining tiles out around that slot, so insert directly there.
|
||||
rack.splice(toIndex, 0, tok);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
this.renderRack();
|
||||
}
|
||||
|
||||
squareAt(x, y) {
|
||||
const col = Math.floor((x - BOARD_X0) / SQ);
|
||||
const row = Math.floor((y - BOARD_Y0) / SQ);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
this.playfield = data.playfield ?? null;
|
||||
this.playerWins = data.playerWins ?? 0; // series score
|
||||
this.aiWins = data.aiWins ?? 0;
|
||||
this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts
|
||||
|
||||
// round-level state
|
||||
this.gs = null;
|
||||
|
|
@ -159,7 +160,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5, 0).setDepth(depth + 1);
|
||||
|
||||
const opx = GAME_WIDTH - 120;
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth);
|
||||
this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth, { playIntro: !this.skipIntro });
|
||||
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);
|
||||
|
|
@ -337,12 +338,9 @@ export default class WordleGame extends Phaser.Scene {
|
|||
this.updateKeyboardColors(this.gs);
|
||||
playSound(this, SFX.PENCIL_WRITE);
|
||||
|
||||
const { over, won } = isGameOver(this.gs);
|
||||
const { over } = isGameOver(this.gs);
|
||||
if (over) {
|
||||
this.playerDone = true;
|
||||
if (!this.aiDone) {
|
||||
this.opponentPortrait?.playEmotion(won ? 'upset' : 'happy');
|
||||
}
|
||||
this.animating = false;
|
||||
this.checkAndHandleGameOver();
|
||||
} else {
|
||||
|
|
@ -377,12 +375,9 @@ export default class WordleGame extends Phaser.Scene {
|
|||
await this.animateTileFlip(this.aiTiles[row], guess, evaluation);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
|
||||
const { over, won } = isGameOver(this.aiGs);
|
||||
const { over } = isGameOver(this.aiGs);
|
||||
if (over) {
|
||||
this.aiDone = true;
|
||||
if (!this.playerDone) {
|
||||
this.opponentPortrait?.playEmotion(won ? 'happy' : 'upset');
|
||||
}
|
||||
this.checkAndHandleGameOver();
|
||||
} else {
|
||||
this.scheduleAITurn();
|
||||
|
|
@ -498,6 +493,9 @@ export default class WordleGame extends Phaser.Scene {
|
|||
isDraw = true;
|
||||
}
|
||||
|
||||
// Opponent reacts to the finished round: happy if the AI won, upset if it lost.
|
||||
if (!isDraw) this.opponentPortrait?.playEmotion(roundWon ? 'upset' : 'happy');
|
||||
|
||||
// Update series scores (draws give no point)
|
||||
if (!isDraw) {
|
||||
if (roundWon) this.playerWins++;
|
||||
|
|
@ -559,7 +557,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
startNextRound() {
|
||||
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins });
|
||||
this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins, skipIntro: true });
|
||||
}
|
||||
|
||||
// ── Fireworks victory screen ───────────────────────────────────────────────
|
||||
|
|
@ -621,7 +619,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
if (!playerWon) plrPortrait.fadeToEliminated(900);
|
||||
|
||||
// Opponent portrait (right)
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2);
|
||||
const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2, { playIntro: false });
|
||||
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);
|
||||
|
|
@ -636,7 +634,7 @@ export default class WordleGame extends Phaser.Scene {
|
|||
// ── Buttons ────────────────────────────────────────────────────────────
|
||||
const btnsY = bot - 64;
|
||||
new Button(this, cx - 130, btnsY, 'Play Again', () => {
|
||||
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0 });
|
||||
this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0, skipIntro: false });
|
||||
}, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1);
|
||||
|
||||
new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import CheckersGame from './games/checkers/CheckersGame.js';
|
|||
import ChessGame from './games/chess/ChessGame.js';
|
||||
import WordleGame from './games/wordle/WordleGame.js';
|
||||
import ScrabbleGame from './games/scrabble/ScrabbleGame.js';
|
||||
import GhostGame from './games/ghost/GhostGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -79,6 +80,7 @@ const config = {
|
|||
ChessGame,
|
||||
WordleGame,
|
||||
ScrabbleGame,
|
||||
GhostGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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', wordle: 'WordleGame', scrabble: 'ScrabbleGame' };
|
||||
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', scrabble: 'ScrabbleGame', ghost: 'GhostGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -366,7 +366,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', 'wordle', 'scrabble'].includes(this.gameDef.slug)) {
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost'].includes(this.gameDef.slug)) {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ function drawBacking(scene, x, y, radius, depth) {
|
|||
|
||||
// ── Opponent portrait (video + sprite fallback) ───────────────────────────────
|
||||
// Returns { playEmotion(emotion), destroy() }
|
||||
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth) {
|
||||
export function createOpponentPortrait(scene, opponent, worldX, worldY, radius, depth, { playIntro = true } = {}) {
|
||||
injectEmotionStyles();
|
||||
const size = radius * 2;
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ export function createOpponentPortrait(scene, opponent, worldX, worldY, radius,
|
|||
|
||||
scene.events.once('shutdown', destroy);
|
||||
|
||||
if (opponent?.speech?.intro?.length) {
|
||||
if (playIntro && opponent?.speech?.intro?.length) {
|
||||
const clips = opponent.speech.intro;
|
||||
enqueueSpeech(clips[Math.floor(Math.random() * clips.length)], {
|
||||
onStart: () => startVisualizer('intro'),
|
||||
|
|
|
|||
|
|
@ -46,3 +46,4 @@ registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlay
|
|||
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 });
|
||||
registerGame({ slug: 'scrabble', name: 'Scrabble', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
// Server-side Ghost dictionary + perfect-play AI.
|
||||
//
|
||||
// Ghost: players alternate appending one letter to a shared fragment. The player
|
||||
// who appends a letter LOSES the round if that letter completes a valid word
|
||||
// (length >= MIN_LEN) or makes the fragment no longer a prefix of any valid word.
|
||||
//
|
||||
// We build a trie of every ENABLE word of length >= MIN_LEN and precompute, once,
|
||||
// a game-theoretic value per node: `node.loss` is true when the player whose turn
|
||||
// it is to move FROM that fragment loses under optimal play. With that table every
|
||||
// AI move is O(26): walk to the fragment's node and read its children's values.
|
||||
|
||||
const MIN_LEN = 4;
|
||||
|
||||
let TRIE = null;
|
||||
|
||||
export function initGhostDictionary(words) {
|
||||
TRIE = { children: Object.create(null), terminal: false, loss: false };
|
||||
for (const w of words) {
|
||||
if (w.length < MIN_LEN) continue;
|
||||
let node = TRIE;
|
||||
for (const ch of w) {
|
||||
node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false, loss: false });
|
||||
}
|
||||
node.terminal = true;
|
||||
}
|
||||
computeLoss(TRIE);
|
||||
}
|
||||
|
||||
export function dictionaryReady() {
|
||||
return TRIE !== null;
|
||||
}
|
||||
|
||||
// Post-order: a node is a LOSS for the player to move iff it has no "winning" move.
|
||||
// A winning move is appending a letter L whose child is NOT a word (a safe
|
||||
// continuation) AND hands the opponent a losing position. Letters whose child is
|
||||
// terminal complete a word and so lose for the mover — never winning moves.
|
||||
function computeLoss(node) {
|
||||
let win = false;
|
||||
for (const L in node.children) {
|
||||
const child = node.children[L];
|
||||
computeLoss(child);
|
||||
if (!child.terminal && child.loss) win = true;
|
||||
}
|
||||
node.loss = !win;
|
||||
}
|
||||
|
||||
function nodeFor(fragment) {
|
||||
let node = TRIE;
|
||||
for (const ch of fragment) {
|
||||
node = node.children[ch];
|
||||
if (!node) return null;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── Public: judge a completed fragment (used to grade the human's letter) ──────
|
||||
|
||||
// Returns { isWord, isPrefix }:
|
||||
// isWord — fragment is a complete valid word (length >= MIN_LEN) -> mover loses
|
||||
// isPrefix — fragment is a prefix of some valid word; if false the mover went
|
||||
// "off-dictionary" and loses.
|
||||
export function judge(fragment) {
|
||||
const F = String(fragment).toUpperCase();
|
||||
const node = nodeFor(F);
|
||||
if (!node) return { isWord: false, isPrefix: false };
|
||||
return { isWord: !!node.terminal, isPrefix: true };
|
||||
}
|
||||
|
||||
// ── Public: choose the AI's letter ─────────────────────────────────────────────
|
||||
|
||||
// Skill profiles. `blunder` = chance of a careless pick from ALL legal letters
|
||||
// (which may complete a word and lose). `foresight` = chance, on a careful turn,
|
||||
// of choosing a move that provably hands the opponent a losing position.
|
||||
const SKILL = {
|
||||
5: { blunder: 0.00, foresight: 1.0 },
|
||||
4: { blunder: 0.05, foresight: 1.0 },
|
||||
3: { blunder: 0.18, foresight: 0.5 },
|
||||
2: { blunder: 0.40, foresight: 0.0 },
|
||||
1: { blunder: 0.65, foresight: 0.0 },
|
||||
};
|
||||
|
||||
// Returns { letter, isWord, isPrefix } for the resulting fragment, mirroring judge().
|
||||
// letter is null only when the AI is at a dead end (should already have ended).
|
||||
export function chooseLetter(fragment, skill) {
|
||||
const F = String(fragment).toUpperCase();
|
||||
const node = nodeFor(F);
|
||||
if (!node) return { letter: null, isWord: false, isPrefix: false };
|
||||
|
||||
const legal = Object.keys(node.children); // every child is a valid prefix
|
||||
if (legal.length === 0) return { letter: null, isWord: false, isPrefix: false };
|
||||
|
||||
const profile = SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3];
|
||||
const safe = legal.filter(L => !node.children[L].terminal); // don't complete a word
|
||||
|
||||
let letter;
|
||||
if (safe.length === 0) {
|
||||
// Forced: every legal letter completes a word -> the AI self-loses this turn.
|
||||
letter = randomFrom(legal);
|
||||
} else if (Math.random() < profile.blunder) {
|
||||
// Careless: pick any legal letter, which may accidentally complete a word.
|
||||
letter = randomFrom(legal);
|
||||
} else {
|
||||
// Careful: never complete a word; use foresight to hand off a losing position.
|
||||
const winning = safe.filter(L => node.children[L].loss);
|
||||
letter = (winning.length && Math.random() < profile.foresight)
|
||||
? randomFrom(winning)
|
||||
: randomFrom(safe);
|
||||
}
|
||||
|
||||
const child = node.children[letter];
|
||||
return { letter, isWord: !!child.terminal, isPrefix: true };
|
||||
}
|
||||
|
||||
function randomFrom(arr) {
|
||||
return arr[Math.floor(Math.random() * arr.length)];
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js';
|
||||
import { initGhostDictionary, judge as ghostJudge, chooseLetter as ghostChooseLetter } from './ghostEngine.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||
|
|
@ -119,6 +120,11 @@ function loadWordLists() {
|
|||
initScrabbleDictionary(scrabbleWords);
|
||||
console.log(`[words] loaded ${scrabbleWords.size} Scrabble words (2–15 letters)`);
|
||||
|
||||
// Ghost dictionary: every ENABLE word of length >= 4 (shorter words don't count).
|
||||
const ghostWords = allWords.filter(w => w.length >= 4 && /^[A-Z]+$/.test(w));
|
||||
initGhostDictionary(ghostWords);
|
||||
console.log(`[words] loaded ${ghostWords.length} Ghost words (4+ letters)`);
|
||||
|
||||
// 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));
|
||||
|
|
@ -179,4 +185,22 @@ router.post('/scrabble/ai-move', (req, res) => {
|
|||
res.json(move);
|
||||
});
|
||||
|
||||
// ── Ghost ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/words/ghost/judge { fragment: string }
|
||||
// Grades the fragment a player just formed: a completed word, or off-dictionary,
|
||||
// both lose the round for whoever made the move.
|
||||
router.post('/ghost/judge', (req, res) => {
|
||||
const fragment = String(req.body?.fragment ?? '');
|
||||
res.json(ghostJudge(fragment));
|
||||
});
|
||||
|
||||
// POST /api/words/ghost/ai-move { fragment: string, skill: number }
|
||||
// Returns the AI's letter and how the resulting fragment grades.
|
||||
router.post('/ghost/ai-move', (req, res) => {
|
||||
const fragment = String(req.body?.fragment ?? '');
|
||||
const skill = Number(req.body?.skill) || 3;
|
||||
res.json(ghostChooseLetter(fragment, skill));
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
Loading…
Reference in New Issue