feat: add Kiitos word game with 3-round progression and AI
- Implement Kiitos game logic: build-a-word mechanic with forced plays, deviations, and pass resolution when stuck - Add 3-round progression escalating rules (4+ words, prepend, super-Kiitos insert-anywhere, double scoring) - Create server-side dictionary engine with prefix trie, word validation, and skill-aware AI move selection - Build cozy Nordic café-themed Phaser scene with drag-and-drop letter cards - Support 1 human + up to 3 AI opponents with turn-based play - Integrate Kiitos into game menu, room scene, and opponent selection - Add game icon to game-icons sheet
This commit is contained in:
parent
dfd4950eab
commit
8a69710946
|
|
@ -0,0 +1,28 @@
|
|||
// Kiitos AI (client side) — a thin, skill-aware wrapper over the server engine's
|
||||
// move chooser. The heavy dictionary work (prefix search, word finding) lives in
|
||||
// server/words/kiitosEngine.js so the full ENABLE list never ships to the browser;
|
||||
// this module just asks for a move and paces it so turns feel deliberate.
|
||||
|
||||
import { api } from '../../services/api.js';
|
||||
|
||||
// Returns one of:
|
||||
// { type:'play', letter, forced:true } — mandatory next letter
|
||||
// { type:'deviate', letter, prefix, word } — repurpose / start a word
|
||||
// { type:'pass' } — no legal move
|
||||
export async function chooseAIMove({ hand, built, targetWord, minLen, skill, allowPrepend, superKiitos }) {
|
||||
try {
|
||||
return await api.post('/words/kiitos/ai-move', {
|
||||
hand, built, targetWord, minLen, skill, allowPrepend, superKiitos,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[kiitos] ai-move failed:', err);
|
||||
return { type: 'pass' };
|
||||
}
|
||||
}
|
||||
|
||||
// A short think time so the AI doesn't snap instantly. Stronger players "decide"
|
||||
// a touch faster; everyone gets a little jitter.
|
||||
export function thinkDelay(skill = 3) {
|
||||
const k = Math.max(1, Math.min(5, skill | 0));
|
||||
return 420 + (5 - k) * 90 + Math.random() * 520;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Kiitos static data — no Phaser, no network. Imported by both the browser scene
|
||||
// and the Node self-play harness, so this file stays pure data + helpers.
|
||||
|
||||
// ── Letter deck ────────────────────────────────────────────────────────────────
|
||||
// 72 letter cards, weighted toward English frequency with a healthy vowel supply
|
||||
// so words actually come together. Counts sum to exactly 72.
|
||||
export const LETTER_COUNTS = {
|
||||
E: 8, A: 7, I: 5, O: 5, T: 5,
|
||||
N: 4, R: 4, S: 4,
|
||||
L: 3, U: 3, D: 3,
|
||||
C: 2, M: 2, H: 2, G: 2, P: 2, B: 2,
|
||||
F: 1, K: 1, V: 1, W: 1, Y: 1, J: 1, Q: 1, X: 1, Z: 1,
|
||||
};
|
||||
|
||||
export const DECK_SIZE = Object.values(LETTER_COUNTS).reduce((a, b) => a + b, 0); // 72
|
||||
export const HAND_SIZE = 8;
|
||||
export const TOTAL_ROUNDS = 3;
|
||||
|
||||
// Build a fresh ordered deck (array of single-letter strings).
|
||||
export function buildDeck() {
|
||||
const deck = [];
|
||||
for (const [letter, n] of Object.entries(LETTER_COUNTS)) {
|
||||
for (let i = 0; i < n; i++) deck.push(letter);
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
|
||||
// ── Round progression (the "green Kiitos" rule flips) ───────────────────────────
|
||||
// Faithful 3-round arc: each round flips one more rule on, ending in full chaos.
|
||||
// minLen — shortest word you may complete
|
||||
// pointsPerCard — score multiplier per banked / penalised card
|
||||
// allowPrepend — may add the new letter at the START of the sequence
|
||||
// superKiitos — may insert the new letter ANYWHERE in the sequence
|
||||
export const ROUND_CONFIG = [
|
||||
{ round: 1, minLen: 4, pointsPerCard: 1, allowPrepend: false, superKiitos: false,
|
||||
rules: 'Build words of 4+ letters. Add letters to the end.' },
|
||||
{ round: 2, minLen: 5, pointsPerCard: 1, allowPrepend: true, superKiitos: false,
|
||||
rules: 'Words now need 5+ letters. You may add to the front, too.' },
|
||||
{ round: 3, minLen: 5, pointsPerCard: 2, allowPrepend: true, superKiitos: true,
|
||||
rules: 'Super-Kiitos! Cards score double and letters slot in anywhere.' },
|
||||
];
|
||||
|
||||
export function roundConfig(round) {
|
||||
return ROUND_CONFIG[Math.max(0, Math.min(ROUND_CONFIG.length - 1, round - 1))];
|
||||
}
|
||||
|
||||
// ── Cozy Nordic café theme ──────────────────────────────────────────────────────
|
||||
export const THEME = {
|
||||
tableTop: 0x6b4429, // warm walnut table
|
||||
tableEdge: 0x49301d,
|
||||
tableGrain: 0x7d5132,
|
||||
cloth: 0x2f6f5e, // muted teal runner down the centre
|
||||
clothEdge: 0x255849,
|
||||
kraft: 0xe7d2a8, // kraft-paper card face
|
||||
kraftTop: 0xf3e3c2,
|
||||
kraftEdge: 0xb79a6a,
|
||||
inkHex: '#4a320e', // espresso ink
|
||||
cocoa: 0x7a5230,
|
||||
cream: '#fbf3df',
|
||||
creamFill: 0xfbf3df,
|
||||
steam: 0xffffff,
|
||||
accent: 0xc8794a, // terracotta
|
||||
accentHex: '#c8794a',
|
||||
positive: 0x4e8d6e, // banked-word green
|
||||
positiveHex:'#4e8d6e',
|
||||
negative: 0xb05a4a, // penalty red-clay
|
||||
negativeHex:'#b05a4a',
|
||||
};
|
||||
|
||||
export const ICON_FRAME = 47;
|
||||
|
|
@ -0,0 +1,816 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { TextInput } from '../../ui/TextInput.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { KiitosLogic } from './KiitosLogic.js';
|
||||
import { THEME, TOTAL_ROUNDS } from './KiitosData.js';
|
||||
import { chooseAIMove, thinkDelay } from './KiitosAI.js';
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const D = { bg: 0, table: 1, center: 10, centerTxt: 12, panel: 16, hand: 20, ui: 26, bubble: 30, overlay: 40, overlayUI: 42 };
|
||||
const CENTER_Y = 430;
|
||||
const CARD_W = 70, CARD_H = 96, CARD_GAP = 10;
|
||||
const HAND_Y = 902;
|
||||
const HAND_W = 86, HAND_H = 120, HAND_GAP = 14;
|
||||
|
||||
// Positions at which inserting one letter turns sequence `a` into `b`
|
||||
// (|b| === |a| + 1). Used to map an AI hint's prefix back onto a drop slot.
|
||||
function insertionsFromTo(a, b) {
|
||||
const res = [];
|
||||
for (let i = 0; i < b.length; i++) {
|
||||
if (b.slice(0, i) + b.slice(i + 1) === a) res.push({ index: i, letter: b[i] });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export default class KiitosGame extends Phaser.Scene {
|
||||
constructor() { super('KiitosGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
const opps = (data.opponents ?? []).slice(0, 3);
|
||||
this.seats = [
|
||||
{ id: 'player', name: 'You', isHuman: true, skill: 5, spriteIndex: -1 },
|
||||
...opps.map((o) => ({
|
||||
id: o.id, name: o.name ?? 'Rival', isHuman: false,
|
||||
skill: o.skill ?? 3, spriteIndex: o.spriteIndex ?? 0,
|
||||
})),
|
||||
];
|
||||
this.busy = false;
|
||||
}
|
||||
|
||||
create() {
|
||||
this.drawTable();
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
|
||||
this.add.text(46, 38, 'Kiitos', {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '60px', color: THEME.accentHex,
|
||||
}).setDepth(D.ui);
|
||||
this.add.text(48, 104, 'say "thank you" when a rival finishes your word', {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: '#f3e3c2',
|
||||
}).setDepth(D.ui).setAlpha(0.7);
|
||||
|
||||
new Button(this, GAME_WIDTH - 96, GAME_HEIGHT - 40, 'Leave',
|
||||
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 150, height: 46, fontSize: 20 })
|
||||
.setDepth(D.ui);
|
||||
|
||||
this.logic = new KiitosLogic(this.seats);
|
||||
this.roundObjs = [];
|
||||
this.handCards = [];
|
||||
this.slotObjs = [];
|
||||
this.registerDragHandlers();
|
||||
this.events.once('shutdown', () => this.cleanup());
|
||||
|
||||
this.showRoundIntro();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.input?.removeAllListeners();
|
||||
this.clearHumanControls();
|
||||
}
|
||||
|
||||
// ── Table backdrop ──────────────────────────────────────────────────────────
|
||||
drawTable() {
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, THEME.tableTop).setDepth(D.bg);
|
||||
const g = this.add.graphics().setDepth(D.table);
|
||||
// subtle grain stripes
|
||||
g.lineStyle(40, THEME.tableGrain, 0.12);
|
||||
for (let x = -100; x < GAME_WIDTH + 100; x += 120) g.lineBetween(x, 0, x + 220, GAME_HEIGHT);
|
||||
// teal runner down the middle
|
||||
g.fillStyle(THEME.cloth, 1);
|
||||
g.fillRoundedRect(GAME_WIDTH / 2 - 560, 150, 1120, 760, 28);
|
||||
g.lineStyle(6, THEME.clothEdge, 1);
|
||||
g.strokeRoundedRect(GAME_WIDTH / 2 - 560, 150, 1120, 760, 28);
|
||||
g.fillStyle(THEME.clothEdge, 0.5);
|
||||
g.fillRoundedRect(GAME_WIDTH / 2 - 540, 168, 1080, 6, 3);
|
||||
}
|
||||
|
||||
// ── Round intro ─────────────────────────────────────────────────────────────
|
||||
showRoundIntro() {
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const cfg = this.logic.config;
|
||||
const first = this.logic.round === 1;
|
||||
const objs = [];
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(D.overlay);
|
||||
objs.push(dim);
|
||||
const panelH = 560;
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(THEME.creamFill, 1);
|
||||
panel.fillRoundedRect(cx - 440, cy - panelH / 2, 880, panelH, 24);
|
||||
panel.lineStyle(4, THEME.kraftEdge, 1);
|
||||
panel.strokeRoundedRect(cx - 440, cy - panelH / 2, 880, panelH, 24);
|
||||
panel.postFX.addShadow(0, 8, 0.02, 1.2, 0x000000, 14, 0.6);
|
||||
objs.push(panel);
|
||||
|
||||
const top = cy - panelH / 2;
|
||||
objs.push(this.add.text(cx, top + 80, 'Kiitos', {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '92px', color: THEME.accentHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
objs.push(this.add.text(cx, top + 160, `Round ${this.logic.round} of ${TOTAL_ROUNDS}`, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '44px', color: THEME.inkHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
objs.push(this.add.text(cx, top + 230, cfg.rules, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: '#6a4a22',
|
||||
align: 'center', wordWrap: { width: 720 },
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
|
||||
if (!first) {
|
||||
const standings = this.logic.standings();
|
||||
standings.forEach((p, i) => {
|
||||
objs.push(this.add.text(cx, top + 310 + i * 40, `${p.name}: ${p.total}`, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '32px',
|
||||
color: p.isHuman ? THEME.accentHex : THEME.inkHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
});
|
||||
}
|
||||
|
||||
const begin = new Button(this, cx, top + panelH - 76, first ? 'Start Playing' : 'Begin Round',
|
||||
() => { objs.forEach((o) => o.destroy()); this.beginRound(); },
|
||||
{ width: 320, height: 70, fontSize: 30, bg: THEME.accent });
|
||||
begin.setDepth(D.overlayUI);
|
||||
objs.push(begin);
|
||||
}
|
||||
|
||||
beginRound() {
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
this.buildBoard();
|
||||
this.renderState();
|
||||
this.time.delayedCall(400, () => this.nextTurn());
|
||||
}
|
||||
|
||||
// ── Persistent board for a round ────────────────────────────────────────────
|
||||
buildBoard() {
|
||||
this.destroyBoard();
|
||||
this.roundObjs = [];
|
||||
|
||||
// round/rules banner
|
||||
const cfg = this.logic.config;
|
||||
this.bannerTxt = this.add.text(GAME_WIDTH / 2, 196, '', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: '#fbf3df',
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
this.bannerTxt.setText(`Round ${this.logic.round} · ${cfg.rules}`);
|
||||
this.roundObjs.push(this.bannerTxt);
|
||||
|
||||
// owner / target line
|
||||
this.ownerTxt = this.add.text(GAME_WIDTH / 2, CENTER_Y - 96, '', {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '34px', color: '#fbf3df',
|
||||
}).setOrigin(0.5).setDepth(D.centerTxt);
|
||||
this.roundObjs.push(this.ownerTxt);
|
||||
|
||||
this.centerLayer = this.add.container(0, 0).setDepth(D.center);
|
||||
this.roundObjs.push(this.centerLayer);
|
||||
|
||||
// turn prompt (under the centre)
|
||||
this.promptTxt = this.add.text(GAME_WIDTH / 2, CENTER_Y + 110, '', {
|
||||
fontFamily: 'Righteous', fontSize: '28px', color: '#fff', align: 'center',
|
||||
wordWrap: { width: 1000 },
|
||||
}).setOrigin(0.5).setDepth(D.centerTxt);
|
||||
this.roundObjs.push(this.promptTxt);
|
||||
|
||||
// seat panels (AIs across the top arc, human bottom-left summary)
|
||||
this.seatPanels = {};
|
||||
const ais = this.seats.filter((s) => !s.isHuman);
|
||||
ais.forEach((s, i) => {
|
||||
const x = 360 + i * 420;
|
||||
this.seatPanels[s.id] = this.buildSeatPanel(s, x, 250);
|
||||
});
|
||||
this.seatPanels['player'] = this.buildSeatPanel(this.seats[0], 200, 760);
|
||||
|
||||
// Hand cards (top-level so drag coordinates map straight to world space) and
|
||||
// drop slots are tracked in their own arrays, rebuilt each render.
|
||||
this.clearHandCards();
|
||||
this.clearSlots();
|
||||
}
|
||||
|
||||
buildSeatPanel(seat, x, y) {
|
||||
const W = 300, H = 132;
|
||||
const g = this.add.graphics().setDepth(D.panel);
|
||||
g.fillStyle(THEME.creamFill, 0.94);
|
||||
g.fillRoundedRect(x - W / 2, y - H / 2, W, H, 16);
|
||||
g.lineStyle(3, THEME.kraftEdge, 1);
|
||||
g.strokeRoundedRect(x - W / 2, y - H / 2, W, H, 16);
|
||||
this.roundObjs.push(g);
|
||||
|
||||
const glow = this.add.graphics().setDepth(D.panel - 1);
|
||||
this.roundObjs.push(glow);
|
||||
|
||||
let avatar = null;
|
||||
if (seat.spriteIndex >= 0 && this.textures.exists('opponents')) {
|
||||
avatar = this.add.sprite(x - W / 2 + 54, y - 18, 'opponents', seat.spriteIndex).setDepth(D.panel + 1);
|
||||
const sc = 84 / avatar.height; avatar.setScale(sc);
|
||||
const m = this.make.graphics(); m.fillCircle(x - W / 2 + 54, y - 18, 40);
|
||||
avatar.setMask(m.createGeometryMask());
|
||||
this.roundObjs.push(avatar);
|
||||
const ring = this.add.graphics().setDepth(D.panel + 1);
|
||||
ring.lineStyle(3, THEME.accent, 1); ring.strokeCircle(x - W / 2 + 54, y - 18, 40);
|
||||
this.roundObjs.push(ring);
|
||||
} else {
|
||||
const c = this.add.graphics().setDepth(D.panel + 1);
|
||||
c.fillStyle(THEME.accent, 1); c.fillCircle(x - W / 2 + 54, y - 18, 40);
|
||||
this.roundObjs.push(c);
|
||||
this.roundObjs.push(this.add.text(x - W / 2 + 54, y - 18, '☕', { fontSize: '40px' })
|
||||
.setOrigin(0.5).setDepth(D.panel + 2));
|
||||
}
|
||||
|
||||
this.roundObjs.push(this.add.text(x - W / 2 + 100, y - 44, seat.name, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '26px', color: THEME.inkHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1));
|
||||
|
||||
const hand = this.add.text(x - W / 2 + 100, y - 12, '', {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: '#6a4a22',
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
const pos = this.add.text(x - W / 2 + 100, y + 18, '', {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: THEME.positiveHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
const neg = this.add.text(x - W / 2 + 100, y + 44, '', {
|
||||
fontFamily: 'Righteous', fontSize: '18px', color: THEME.negativeHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.panel + 1);
|
||||
this.roundObjs.push(hand, pos, neg);
|
||||
|
||||
return { x, y, W, H, glow, hand, pos, neg };
|
||||
}
|
||||
|
||||
destroyBoard() {
|
||||
(this.roundObjs ?? []).forEach((o) => o.destroy());
|
||||
this.roundObjs = [];
|
||||
this.clearHandCards();
|
||||
this.clearSlots();
|
||||
this.clearHumanControls();
|
||||
}
|
||||
|
||||
// ── Rendering current state ─────────────────────────────────────────────────
|
||||
renderState() {
|
||||
const c = this.logic.center;
|
||||
const cfg = this.logic.config;
|
||||
|
||||
// owner / target headline
|
||||
if (c.targetWord) {
|
||||
const owner = this.logic.players[c.ownerIdx];
|
||||
this.ownerTxt.setText(`${owner.name === 'You' ? 'You are' : owner.name + ' is'} spelling "${c.targetWord}"`);
|
||||
} else {
|
||||
const cur = this.logic.current();
|
||||
this.ownerTxt.setText(`${cur.name === 'You' ? 'You' : cur.name} to start a word`);
|
||||
}
|
||||
|
||||
// centre cards. In "deviate view" (the human is choosing where to drop) we show
|
||||
// only the physical cards on the table; otherwise we show the announced target
|
||||
// with placed letters solid and the rest as ghosts.
|
||||
this.centerLayer.removeAll(true);
|
||||
const word = this._deviateView ? c.built : (c.targetWord ?? c.built);
|
||||
const len = Math.max(word.length, 1);
|
||||
const totW = len * CARD_W + (len - 1) * CARD_GAP;
|
||||
const startX = GAME_WIDTH / 2 - totW / 2 + CARD_W / 2;
|
||||
this._rowStartX = startX;
|
||||
this._rowStep = CARD_W + CARD_GAP;
|
||||
let x = startX;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const placed = i < c.built.length;
|
||||
const letter = word[i] ?? '';
|
||||
const card = this.makeCard(x, CENTER_Y, CARD_W, CARD_H, letter, {
|
||||
ghost: !placed, next: !this._deviateView && !placed && i === c.built.length,
|
||||
});
|
||||
this.centerLayer.add(card);
|
||||
x += CARD_W + CARD_GAP;
|
||||
}
|
||||
|
||||
// seat panels
|
||||
for (const seat of this.seats) {
|
||||
const p = this.logic.players.find((q) => q.id === seat.id);
|
||||
const panel = this.seatPanels[seat.id];
|
||||
panel.hand.setText(`hand: ${p.hand.length}`);
|
||||
panel.pos.setText(`banked: ${p.positive.length}`);
|
||||
panel.neg.setText(`penalty: ${p.negative.length}`);
|
||||
panel.glow.clear();
|
||||
if (this.logic.turnIdx === this.seats.indexOf(seat) && !this.logic.roundOver) {
|
||||
panel.glow.fillStyle(THEME.accent, 0.35);
|
||||
panel.glow.fillRoundedRect(panel.x - panel.W / 2 - 8, panel.y - panel.H / 2 - 8, panel.W + 16, panel.H + 16, 20);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderHand();
|
||||
void cfg;
|
||||
}
|
||||
|
||||
renderHand() {
|
||||
this.clearHandCards();
|
||||
const human = this.logic.players[0];
|
||||
const hand = human.hand;
|
||||
const n = hand.length;
|
||||
const totW = n * HAND_W + (n - 1) * HAND_GAP;
|
||||
let x = GAME_WIDTH / 2 - totW / 2 + HAND_W / 2;
|
||||
|
||||
// Which cards can be dragged this turn: only the forced letter when forced,
|
||||
// otherwise every card. (Disabled entirely once a card is in flight/staged.)
|
||||
const myTurn = this._humanActive && this.logic.turnIdx === 0 && !this._tentative;
|
||||
const forced = this._humanForced;
|
||||
let forcedTagged = false;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const letter = hand[i];
|
||||
const isForcedCard = forced && letter === forced && !forcedTagged;
|
||||
const draggable = myTurn && (forced ? isForcedCard : true);
|
||||
const card = this.makeCard(x, HAND_Y, HAND_W, HAND_H, letter,
|
||||
{ hand: true, highlight: isForcedCard });
|
||||
card.setData('letter', letter);
|
||||
card.setData('homeX', x);
|
||||
card.setData('homeY', HAND_Y);
|
||||
card.setDepth(D.hand);
|
||||
if (draggable) {
|
||||
if (isForcedCard) forcedTagged = true;
|
||||
card.setSize(HAND_W, HAND_H);
|
||||
// A Container's displayOrigin is width*0.5, so the auto Rectangle(0,0,w,h)
|
||||
// hit area lands centred. A manual (-w/2,-h/2,w,h) rect double-shifts it
|
||||
// up-left — don't (see ScrabbleGame.renderRack).
|
||||
card.setInteractive({ useHandCursor: true });
|
||||
card.input.cursor = 'grab';
|
||||
this.input.setDraggable(card);
|
||||
card.setData('draggable', true);
|
||||
}
|
||||
this.handCards.push(card);
|
||||
x += HAND_W + HAND_GAP;
|
||||
}
|
||||
}
|
||||
|
||||
clearHandCards() {
|
||||
(this.handCards ?? []).forEach((c) => c.destroy());
|
||||
this.handCards = [];
|
||||
}
|
||||
|
||||
// Build a kraft letter card as a container (bg graphics at index 0 + text).
|
||||
makeCard(x, y, w, h, letter, opts = {}) {
|
||||
const cont = this.add.container(x, y);
|
||||
const g = this.add.graphics();
|
||||
const s = { w2: w / 2, h2: h / 2 };
|
||||
if (opts.ghost) {
|
||||
g.fillStyle(0x000000, 0.18);
|
||||
g.fillRoundedRect(-s.w2, -s.h2, w, h, 10);
|
||||
g.lineStyle(2, THEME.kraftEdge, 0.7);
|
||||
g.strokeRoundedRect(-s.w2, -s.h2, w, h, 10);
|
||||
} else {
|
||||
g.fillStyle(THEME.kraftEdge, 1);
|
||||
g.fillRoundedRect(-s.w2 + 2, -s.h2 + 4, w, h, 10);
|
||||
g.fillStyle(opts.highlight ? THEME.kraftTop : THEME.kraft, 1);
|
||||
g.fillRoundedRect(-s.w2, -s.h2, w, h, 10);
|
||||
g.fillStyle(THEME.kraftTop, 0.6);
|
||||
g.fillRoundedRect(-s.w2 + 6, -s.h2 + 6, w - 12, h * 0.34, 8);
|
||||
g.lineStyle(opts.highlight ? 4 : 2, opts.highlight ? THEME.accent : THEME.kraftEdge, 1);
|
||||
g.strokeRoundedRect(-s.w2, -s.h2, w, h, 10);
|
||||
}
|
||||
if (opts.next) {
|
||||
g.lineStyle(3, THEME.accent, 0.9);
|
||||
g.strokeRoundedRect(-s.w2, -s.h2, w, h, 10);
|
||||
}
|
||||
cont.add(g);
|
||||
const txt = this.add.text(0, 0, letter, {
|
||||
fontFamily: 'Righteous', fontSize: `${Math.round(h * 0.46)}px`,
|
||||
color: opts.ghost ? '#8a6a3a' : THEME.inkHex,
|
||||
}).setOrigin(0.5);
|
||||
if (opts.ghost) txt.setAlpha(0.5);
|
||||
cont.add(txt);
|
||||
return cont;
|
||||
}
|
||||
|
||||
// ── Turn loop ───────────────────────────────────────────────────────────────
|
||||
nextTurn() {
|
||||
if (this.logic.roundOver) { this.endRound(); return; }
|
||||
this._humanActive = false;
|
||||
this._humanForced = null;
|
||||
this._deviateView = false;
|
||||
this._tentative = null;
|
||||
this.renderState();
|
||||
const cur = this.logic.current();
|
||||
if (cur.isHuman) this.humanTurn();
|
||||
else this.time.delayedCall(thinkDelay(cur.skill), () => this.aiTurn());
|
||||
}
|
||||
|
||||
async aiTurn() {
|
||||
if (this.logic.roundOver) { this.endRound(); return; }
|
||||
const cfg = this.logic.config;
|
||||
const cur = this.logic.current();
|
||||
this.promptTxt.setText(`${cur.name} is thinking…`);
|
||||
const move = await chooseAIMove({
|
||||
hand: cur.hand, built: this.logic.center.built, targetWord: this.logic.center.targetWord,
|
||||
minLen: cfg.minLen, skill: cur.skill,
|
||||
allowPrepend: cfg.allowPrepend, superKiitos: cfg.superKiitos,
|
||||
});
|
||||
this.applyMove(move, this.logic.turnIdx);
|
||||
}
|
||||
|
||||
applyMove(move, playerIdx) {
|
||||
let evt;
|
||||
if (move.type === 'play') {
|
||||
evt = this.logic.applyForced(playerIdx);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
} else if (move.type === 'deviate') {
|
||||
evt = this.logic.applyDeviate(playerIdx, move.letter, move.prefix ?? move.newBuilt, move.word);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
} else {
|
||||
evt = this.logic.applyPass(playerIdx);
|
||||
if (evt.resolved) {
|
||||
const owner = this.logic.players[evt.resolved.ownerIdx];
|
||||
this.flashCenter(`${owner.name === 'You' ? 'Your' : owner.name + "'s"} word fizzled — ${evt.resolved.cards} cards to penalty`, THEME.negativeHex);
|
||||
playSound(this, SFX.CARD_SHUFFLE);
|
||||
}
|
||||
}
|
||||
this.renderState();
|
||||
|
||||
if (evt?.completed) {
|
||||
const owner = this.logic.players[evt.ownerIdx];
|
||||
this.kiitosBubble(owner, evt.word);
|
||||
}
|
||||
|
||||
if (evt?.roundOver) { this.time.delayedCall(700, () => this.endRound()); return; }
|
||||
this.time.delayedCall(evt?.completed ? 1100 : 360, () => this.nextTurn());
|
||||
}
|
||||
|
||||
// ── Human turn (drag & drop) ──────────────────────────────────────────────────
|
||||
humanTurn() {
|
||||
this.clearHumanControls();
|
||||
this._humanActive = true;
|
||||
this._tentative = null;
|
||||
const forced = this.logic.forcedLetter(0);
|
||||
this._humanForced = forced;
|
||||
this._deviateView = !forced; // deviation shows the bare table + drop slots
|
||||
this.renderState(); // draws the centre + (re)builds draggable hand
|
||||
this.buildSlots();
|
||||
this.buildTurnButtons();
|
||||
|
||||
const built = this.logic.center.built;
|
||||
if (forced) {
|
||||
const owner = this.logic.players[this.logic.center.ownerIdx];
|
||||
const tag = owner.id === 'player' ? 'your own word' : `${owner.name}'s word`;
|
||||
this.promptTxt.setText(`You must play "${forced}" — drag it onto the slot to finish ${tag} ("${this.logic.center.targetWord}").`);
|
||||
} else {
|
||||
this.promptTxt.setText(built
|
||||
? 'Drag a letter onto a slot to build your word, then name it — or pass.'
|
||||
: 'Drag a letter onto the table to start a word, then name it — or pass.');
|
||||
}
|
||||
}
|
||||
|
||||
buildTurnButtons() {
|
||||
this.feedbackTxt = this.add.text(GAME_WIDTH / 2, CENTER_Y + 260, '', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: THEME.negativeHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
if (this._humanForced) return; // forced play: you must drag the card, no buttons
|
||||
this.humanBtns = [
|
||||
new Button(this, GAME_WIDTH / 2 - 130, CENTER_Y + 200, 'Pass', () => this.humanPass(),
|
||||
{ variant: 'ghost', width: 180, height: 54, fontSize: 22 }).setDepth(D.ui),
|
||||
new Button(this, GAME_WIDTH / 2 + 130, CENTER_Y + 200, 'Hint', () => this.humanHint(),
|
||||
{ variant: 'ghost', width: 180, height: 54, fontSize: 22 }).setDepth(D.ui),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Drag plumbing (registered once) ──────────────────────────────────────────
|
||||
registerDragHandlers() {
|
||||
this.input.on('dragstart', (p, obj) => {
|
||||
if (!obj.active || !obj.getData?.('draggable')) return;
|
||||
obj.setData('dragging', true);
|
||||
obj.setDepth(D.bubble);
|
||||
if (obj.input) obj.input.cursor = 'grabbing';
|
||||
this.highlightSlots(true);
|
||||
});
|
||||
this.input.on('drag', (p, obj, dx, dy) => {
|
||||
if (!obj.active || !obj.getData?.('dragging')) return;
|
||||
obj.x = dx; obj.y = dy;
|
||||
});
|
||||
this.input.on('drop', (p, obj, zone) => {
|
||||
if (!obj.active || !obj.getData?.('dragging')) return;
|
||||
obj.setData('dropped', true);
|
||||
this.onCardDropped(obj, zone);
|
||||
});
|
||||
this.input.on('dragend', (p, obj) => {
|
||||
if (!obj.active || !obj.getData?.('dragging')) return;
|
||||
obj.setData('dragging', false);
|
||||
if (obj.input) obj.input.cursor = 'grab';
|
||||
this.highlightSlots(false);
|
||||
if (!obj.getData('dropped')) this.snapBack(obj);
|
||||
obj.setData('dropped', false);
|
||||
});
|
||||
}
|
||||
|
||||
onCardDropped(card, zone) {
|
||||
const letter = card.getData('letter');
|
||||
const insertIndex = zone.getData('insertIndex');
|
||||
const built = this.logic.center.built;
|
||||
|
||||
if (this._humanForced) {
|
||||
if (letter !== this._humanForced || insertIndex !== built.length) { this.snapBack(card); return; }
|
||||
const slotX = zone.getData('mx'); // capture before clearSlots() destroys the zone
|
||||
this._humanActive = false;
|
||||
this.lockDrag();
|
||||
this.clearSlots();
|
||||
this.tweenCardToSlot(card, slotX);
|
||||
this.time.delayedCall(170, () => this.applyMove({ type: 'play', letter, forced: true }, 0));
|
||||
return;
|
||||
}
|
||||
const newBuilt = built.slice(0, insertIndex) + letter + built.slice(insertIndex);
|
||||
this.stageTentative(card, zone, letter, insertIndex, newBuilt);
|
||||
}
|
||||
|
||||
// Freeze the rest of the hand while one card is in play / being named.
|
||||
lockDrag() {
|
||||
for (const c of (this.handCards ?? [])) {
|
||||
if (c.input) this.input.setDraggable(c, false);
|
||||
c.setData('draggable', false);
|
||||
}
|
||||
}
|
||||
|
||||
tweenCardToSlot(card, x) {
|
||||
if (x == null) x = card.x; // defensive: never feed the tween an undefined target
|
||||
card.setDepth(D.centerTxt);
|
||||
this.tweens.add({
|
||||
targets: card, x, y: CENTER_Y, scaleX: CARD_W / HAND_W, scaleY: CARD_H / HAND_H,
|
||||
duration: 170, ease: 'Quad.easeOut',
|
||||
});
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
}
|
||||
|
||||
snapBack(card) {
|
||||
card.setDepth(D.hand);
|
||||
this.tweens.add({
|
||||
targets: card, x: card.getData('homeX'), y: card.getData('homeY'),
|
||||
scaleX: 1, scaleY: 1, duration: 180, ease: 'Quad.easeOut',
|
||||
});
|
||||
}
|
||||
|
||||
stageTentative(card, zone, letter, insertIndex, newBuilt) {
|
||||
const slotX = zone.getData('mx'); // capture before clearSlots() destroys the zone
|
||||
this._tentative = { card, letter, insertIndex, newBuilt };
|
||||
this._humanActive = false;
|
||||
this.lockDrag();
|
||||
this.clearSlots();
|
||||
this.tweenCardToSlot(card, slotX);
|
||||
this.promptNameWord(newBuilt);
|
||||
}
|
||||
|
||||
promptNameWord(newBuilt) {
|
||||
(this.humanBtns ?? []).forEach((b) => b.destroy());
|
||||
this.promptTxt.setText(`Name your word — it must start with "${newBuilt}".`);
|
||||
const cy = CENTER_Y + 200;
|
||||
this.humanInput = new TextInput(this, GAME_WIDTH / 2 - 130, cy, {
|
||||
width: 360, height: 56, value: newBuilt.toLowerCase(),
|
||||
placeholder: `word starting "${newBuilt}"…`, maxLength: 12, autocomplete: 'off',
|
||||
});
|
||||
this.humanInput.focus();
|
||||
this.humanInput.on('keydown', (e) => { if (e.key === 'Enter') this.confirmTentativeWord(); });
|
||||
|
||||
this.feedbackTxt?.destroy();
|
||||
this.feedbackTxt = this.add.text(GAME_WIDTH / 2, cy + 60, '', {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: THEME.negativeHex,
|
||||
}).setOrigin(0.5).setDepth(D.ui);
|
||||
|
||||
this.humanBtns = [
|
||||
new Button(this, GAME_WIDTH / 2 + 130, cy, 'Announce', () => this.confirmTentativeWord(),
|
||||
{ width: 200, height: 56, fontSize: 24, bg: THEME.accent }).setDepth(D.ui),
|
||||
new Button(this, GAME_WIDTH / 2 - 360, cy, 'Cancel', () => this.cancelTentative(),
|
||||
{ variant: 'ghost', width: 150, height: 56, fontSize: 22 }).setDepth(D.ui),
|
||||
];
|
||||
}
|
||||
|
||||
async confirmTentativeWord() {
|
||||
if (!this._tentative || !this.humanInput) return;
|
||||
const cfg = this.logic.config;
|
||||
const { letter, newBuilt } = this._tentative;
|
||||
const W = String(this.humanInput.value).toUpperCase().replace(/[^A-Z]/g, '');
|
||||
if (W.length < cfg.minLen) { this.setFeedback(`Words need ${cfg.minLen}+ letters.`); return; }
|
||||
if (!W.startsWith(newBuilt)) { this.setFeedback(`Your word must start with "${newBuilt}".`); return; }
|
||||
|
||||
let ok = false;
|
||||
try {
|
||||
const r = await api.post('/words/kiitos/validate', { word: W, minLen: cfg.minLen, prefix: newBuilt });
|
||||
ok = !!r.valid;
|
||||
} catch { this.setFeedback('Could not reach the dictionary.'); return; }
|
||||
if (!ok) { this.setFeedback(`"${W}" isn't in the word list.`); return; }
|
||||
|
||||
this._tentative = null;
|
||||
this.clearHumanControls();
|
||||
this.applyMove({ type: 'deviate', letter, prefix: newBuilt, word: W }, 0);
|
||||
}
|
||||
|
||||
cancelTentative() {
|
||||
if (!this._tentative) return;
|
||||
this._tentative = null;
|
||||
this.humanTurn(); // rebuilds the hand at home, slots, and buttons
|
||||
}
|
||||
|
||||
async humanHint() {
|
||||
if (this._tentative || this._humanForced) return;
|
||||
const cfg = this.logic.config;
|
||||
const human = this.logic.players[0];
|
||||
this.setFeedback('thinking…', '#6a4a22');
|
||||
const move = await chooseAIMove({
|
||||
hand: human.hand, built: this.logic.center.built, targetWord: this.logic.center.targetWord,
|
||||
minLen: cfg.minLen, skill: 5, allowPrepend: cfg.allowPrepend, superKiitos: cfg.superKiitos,
|
||||
});
|
||||
if (move.type !== 'deviate') { this.setFeedback('No word found — you may need to pass.', '#6a4a22'); return; }
|
||||
|
||||
const built = this.logic.center.built;
|
||||
const spots = insertionsFromTo(built, move.prefix);
|
||||
const card = this.handCards.find((c) => c.getData('letter') === move.letter && c.getData('draggable'));
|
||||
if (!spots.length || !card) { this.setFeedback(`Try "${move.word}".`, THEME.positiveHex); return; }
|
||||
|
||||
const insertIndex = spots[0].index;
|
||||
const x = this.slotXFor(insertIndex);
|
||||
const fakeZone = { getData: (k) => (k === 'insertIndex' ? insertIndex : k === 'mx' ? x : undefined) };
|
||||
this.stageTentative(card, fakeZone, move.letter, insertIndex, move.prefix);
|
||||
if (this.humanInput) this.humanInput.value = move.word.toLowerCase();
|
||||
this.setFeedback(`Suggested: ${move.word}`, THEME.positiveHex);
|
||||
}
|
||||
|
||||
humanPass() {
|
||||
if (this._tentative) return;
|
||||
this._humanActive = false;
|
||||
this.clearHumanControls();
|
||||
this.applyMove({ type: 'pass' }, 0);
|
||||
}
|
||||
|
||||
// ── Drop slots ────────────────────────────────────────────────────────────────
|
||||
slotXFor(insertIndex) {
|
||||
const built = this.logic.center.built;
|
||||
if (this._humanForced) return this._rowStartX + built.length * this._rowStep;
|
||||
if (!built.length) return GAME_WIDTH / 2;
|
||||
return this._rowStartX + (insertIndex - 0.5) * this._rowStep;
|
||||
}
|
||||
|
||||
buildSlots() {
|
||||
this.clearSlots();
|
||||
if (!this._humanActive || this.logic.turnIdx !== 0) return;
|
||||
const built = this.logic.center.built;
|
||||
const cfg = this.logic.config;
|
||||
|
||||
const indices = [];
|
||||
if (this._humanForced) indices.push(built.length);
|
||||
else if (!built.length) indices.push(0);
|
||||
else {
|
||||
indices.push(built.length); // append at the end
|
||||
if (cfg.allowPrepend) indices.push(0); // or the front
|
||||
if (cfg.superKiitos) for (let k = 1; k < built.length; k++) indices.push(k); // anywhere
|
||||
}
|
||||
|
||||
for (const k of [...new Set(indices)].sort((a, b) => a - b)) {
|
||||
const x = this.slotXFor(k);
|
||||
const zw = CARD_W + 22, zh = CARD_H + 110;
|
||||
const zone = this.add.zone(x, CENTER_Y, zw, zh).setRectangleDropZone(zw, zh).setDepth(D.center);
|
||||
zone.setData('insertIndex', k);
|
||||
zone.setData('mx', x);
|
||||
const marker = this.add.graphics().setDepth(D.centerTxt);
|
||||
this.drawSlotMarker(marker, x, false);
|
||||
this.slotObjs.push(zone, marker);
|
||||
this.slotMarkers.push({ g: marker, x });
|
||||
}
|
||||
}
|
||||
|
||||
drawSlotMarker(g, x, hot) {
|
||||
g.clear();
|
||||
const w = CARD_W + 8, h = CARD_H + 8;
|
||||
g.lineStyle(hot ? 5 : 3, hot ? THEME.accent : THEME.kraftEdge, hot ? 1 : 0.65);
|
||||
g.strokeRoundedRect(x - w / 2, CENTER_Y - h / 2, w, h, 12);
|
||||
g.fillStyle(hot ? THEME.accent : THEME.kraftEdge, hot ? 1 : 0.7);
|
||||
const ty = CENTER_Y - h / 2 - 26;
|
||||
g.fillTriangle(x - 12, ty, x + 12, ty, x, ty + 15);
|
||||
}
|
||||
|
||||
highlightSlots(on) {
|
||||
for (const m of (this.slotMarkers ?? [])) this.drawSlotMarker(m.g, m.x, on);
|
||||
}
|
||||
|
||||
clearSlots() {
|
||||
(this.slotObjs ?? []).forEach((o) => o.destroy());
|
||||
this.slotObjs = [];
|
||||
this.slotMarkers = [];
|
||||
}
|
||||
|
||||
setFeedback(msg, colorHex = THEME.negativeHex) {
|
||||
if (!this.feedbackTxt) return;
|
||||
this.feedbackTxt.setText(msg).setColor(colorHex).setAlpha(1).setScale(1.1);
|
||||
this.tweens.add({ targets: this.feedbackTxt, scale: 1, duration: 140, ease: 'Back.easeOut' });
|
||||
}
|
||||
|
||||
clearHumanControls() {
|
||||
this.humanInput?.destroy(); this.humanInput = null;
|
||||
this.feedbackTxt?.destroy(); this.feedbackTxt = null;
|
||||
(this.humanBtns ?? []).forEach((b) => b.destroy());
|
||||
this.humanBtns = null;
|
||||
this.clearSlots();
|
||||
}
|
||||
|
||||
// ── Effects ─────────────────────────────────────────────────────────────────
|
||||
kiitosBubble(owner, word) {
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
const cx = GAME_WIDTH / 2, cy = CENTER_Y;
|
||||
const cont = this.add.container(cx, cy).setDepth(D.bubble);
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0xffffff, 0.97); g.fillRoundedRect(-220, -70, 440, 140, 22);
|
||||
g.lineStyle(4, THEME.accent, 1); g.strokeRoundedRect(-220, -70, 440, 140, 22);
|
||||
cont.add(g);
|
||||
cont.add(this.add.text(0, -26, 'Kiitos!', {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '52px', color: THEME.accentHex,
|
||||
}).setOrigin(0.5));
|
||||
cont.add(this.add.text(0, 28, `${owner.name === 'You' ? 'You' : owner.name} banked "${word}"`, {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: THEME.inkHex,
|
||||
}).setOrigin(0.5));
|
||||
cont.setScale(0.2);
|
||||
this.tweens.add({ targets: cont, scale: 1, duration: 280, ease: 'Back.easeOut' });
|
||||
this.tweens.add({ targets: cont, alpha: 0, delay: 900, duration: 300, onComplete: () => cont.destroy() });
|
||||
}
|
||||
|
||||
flashCenter(msg, colorHex) {
|
||||
const t = this.add.text(GAME_WIDTH / 2, CENTER_Y + 56, msg, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: colorHex,
|
||||
}).setOrigin(0.5).setDepth(D.bubble);
|
||||
this.tweens.add({ targets: t, y: t.y - 30, alpha: 0, delay: 700, duration: 700, onComplete: () => t.destroy() });
|
||||
}
|
||||
|
||||
// ── Round / match end ───────────────────────────────────────────────────────
|
||||
endRound() {
|
||||
this.clearHumanControls();
|
||||
this.logic.scoreRound();
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const objs = [];
|
||||
objs.push(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6).setDepth(D.overlay));
|
||||
objs.push(this.add.text(cx, 150, `Round ${this.logic.round} — Scores`, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '70px', color: '#fbf3df',
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI));
|
||||
|
||||
const rows = this.logic.standings();
|
||||
rows.forEach((p, i) => {
|
||||
const y = 300 + i * 88;
|
||||
objs.push(this.add.text(cx - 360, y, p.name, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '40px',
|
||||
color: p.isHuman ? THEME.accentHex : '#fbf3df',
|
||||
}).setOrigin(0, 0.5).setDepth(D.overlayUI));
|
||||
objs.push(this.add.text(cx + 40, y, `+${p.positive.length} −${p.negative.length}`, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: '#cbb892',
|
||||
}).setOrigin(0, 0.5).setDepth(D.overlayUI));
|
||||
objs.push(this.add.text(cx + 360, y, `${p.total}`, {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: THEME.accentHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.overlayUI));
|
||||
});
|
||||
|
||||
const last = this.logic.isMatchOver();
|
||||
const btn = new Button(this, cx, GAME_HEIGHT - 130, last ? 'Final Results' : 'Next Round',
|
||||
() => {
|
||||
objs.forEach((o) => o.destroy());
|
||||
if (last) { this.showFinal(); }
|
||||
else { this.logic.advanceRound(); this.showRoundIntro(); }
|
||||
}, { width: 300, height: 66, fontSize: 28, bg: THEME.accent });
|
||||
btn.setDepth(D.overlayUI);
|
||||
objs.push(btn);
|
||||
}
|
||||
|
||||
showFinal() {
|
||||
this.destroyBoard();
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const standings = this.logic.standings();
|
||||
const top = standings[0].total;
|
||||
const winners = standings.filter((p) => p.total === top);
|
||||
const human = this.logic.players[0];
|
||||
const playerWon = human.total === top;
|
||||
const result = playerWon ? (winners.length > 1 ? 'draw' : 'win') : 'loss';
|
||||
this.recordResult(result);
|
||||
|
||||
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.72).setDepth(D.overlay);
|
||||
const panel = this.add.graphics().setDepth(D.overlayUI);
|
||||
panel.fillStyle(THEME.creamFill, 1); panel.fillRoundedRect(cx - 420, cy - 330, 840, 660, 24);
|
||||
panel.lineStyle(4, THEME.kraftEdge, 1); panel.strokeRoundedRect(cx - 420, cy - 330, 840, 660, 24);
|
||||
|
||||
const title = playerWon ? (winners.length > 1 ? 'A Friendly Tie!' : 'Kiitos — You Win!')
|
||||
: `${standings[0].name} Wins!`;
|
||||
this.add.text(cx, cy - 246, title, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '72px', color: THEME.accentHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI + 1);
|
||||
|
||||
standings.forEach((p, i) => {
|
||||
this.add.text(cx - 300, cy - 130 + i * 60, `${i + 1}. ${p.name}`, {
|
||||
fontFamily: 'YummyCupcakes', fontSize: '40px',
|
||||
color: p.isHuman ? THEME.accentHex : THEME.inkHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.overlayUI + 1);
|
||||
this.add.text(cx + 300, cy - 130 + i * 60, `${p.total}`, {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: THEME.inkHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.overlayUI + 1);
|
||||
});
|
||||
|
||||
new Button(this, cx - 160, cy + 250, 'Play Again',
|
||||
() => this.scene.restart(this._initData), { width: 280, height: 62, fontSize: 26, bg: THEME.accent })
|
||||
.setDepth(D.overlayUI + 1);
|
||||
new Button(this, cx + 160, cy + 250, 'Leave',
|
||||
() => this.scene.start('GameMenu'), { variant: 'ghost', width: 280, height: 62, fontSize: 26 })
|
||||
.setDepth(D.overlayUI + 1);
|
||||
}
|
||||
|
||||
async recordResult(result) {
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'kiitos',
|
||||
score: this.logic.players[0].total,
|
||||
opponentScores: this.logic.players.filter((p) => !p.isHuman).map((p) => p.total),
|
||||
result,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
// Pure Kiitos logic — no Phaser, no network, no dictionary. Drives state through
|
||||
// validated moves; word/prefix legality is decided upstream (the server engine for
|
||||
// the AI, the /kiitos/validate route for the human) so this layer stays portable
|
||||
// and unit-testable. Imported by the browser scene and the Node self-play harness.
|
||||
|
||||
import { buildDeck, HAND_SIZE, TOTAL_ROUNDS, roundConfig } from './KiitosData.js';
|
||||
|
||||
function shuffle(arr, rng = Math.random) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export class KiitosLogic {
|
||||
// players: [{ id, name, isHuman, skill }]
|
||||
constructor(players, { rng = Math.random } = {}) {
|
||||
this.rng = rng;
|
||||
this.players = players.map((p) => ({
|
||||
id: p.id, name: p.name, isHuman: !!p.isHuman, skill: p.skill ?? 3,
|
||||
hand: [], positive: [], negative: [], roundScore: 0, total: 0,
|
||||
}));
|
||||
this.round = 0;
|
||||
this.finished = false;
|
||||
this.startRound(1);
|
||||
}
|
||||
|
||||
get n() { return this.players.length; }
|
||||
get config() { return roundConfig(this.round); }
|
||||
|
||||
// ── Round lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
startRound(round) {
|
||||
this.round = round;
|
||||
this.deck = shuffle(buildDeck(), this.rng);
|
||||
this.center = { built: '', cards: [], ownerIdx: -1, targetWord: null };
|
||||
this.consecPasses = 0;
|
||||
this.roundOver = false;
|
||||
for (const p of this.players) {
|
||||
p.hand = this.deck.splice(0, HAND_SIZE);
|
||||
p.positive = [];
|
||||
p.negative = [];
|
||||
p.roundScore = 0;
|
||||
}
|
||||
// Round 1 starts randomly; later rounds the lowest cumulative score leads.
|
||||
if (round === 1) {
|
||||
this.turnIdx = Math.floor(this.rng() * this.n);
|
||||
} else {
|
||||
let lo = 0;
|
||||
for (let i = 1; i < this.n; i++) if (this.players[i].total < this.players[lo].total) lo = i;
|
||||
this.turnIdx = lo;
|
||||
}
|
||||
this.leaderIdx = this.turnIdx;
|
||||
}
|
||||
|
||||
// Refill every hand up to HAND_SIZE from whatever the deck has left.
|
||||
redrawAll() {
|
||||
for (const p of this.players) {
|
||||
while (p.hand.length < HAND_SIZE && this.deck.length) p.hand.push(this.deck.pop());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Queries (no mutation) ────────────────────────────────────────────────────────
|
||||
|
||||
current() { return this.players[this.turnIdx]; }
|
||||
|
||||
// The letter this player is obliged to play, or null if none / not holding it.
|
||||
forcedLetter(playerIdx = this.turnIdx) {
|
||||
const c = this.center;
|
||||
if (!c.targetWord || c.built.length >= c.targetWord.length) return null;
|
||||
const need = c.targetWord[c.built.length];
|
||||
return this.players[playerIdx].hand.includes(need) ? need : null;
|
||||
}
|
||||
|
||||
// Remove one copy of `letter` from a hand. Returns true on success.
|
||||
_takeFromHand(player, letter) {
|
||||
const i = player.hand.indexOf(letter);
|
||||
if (i < 0) return false;
|
||||
player.hand.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
_advanceTurn() { this.turnIdx = (this.turnIdx + 1) % this.n; }
|
||||
|
||||
// ── Moves ────────────────────────────────────────────────────────────────────────
|
||||
// Each returns a small event object the scene animates from.
|
||||
|
||||
// Forced play: append the required letter; may complete the owner's word.
|
||||
applyForced(playerIdx = this.turnIdx) {
|
||||
const player = this.players[playerIdx];
|
||||
const letter = this.forcedLetter(playerIdx);
|
||||
if (!letter) return { type: 'noop' };
|
||||
this._takeFromHand(player, letter);
|
||||
this.center.built += letter;
|
||||
this.center.cards.push(letter);
|
||||
this.consecPasses = 0;
|
||||
|
||||
if (this.center.built === this.center.targetWord) {
|
||||
return this._complete(playerIdx);
|
||||
}
|
||||
this._advanceTurn();
|
||||
return { type: 'play', playerIdx, letter, completed: false };
|
||||
}
|
||||
|
||||
// Deviation / start: `newBuilt` is the rearranged sequence (old centre letters +
|
||||
// the one played letter), `word` the freshly declared target. Caller has already
|
||||
// validated word legality and that newBuilt is a prefix of word.
|
||||
applyDeviate(playerIdx, letter, newBuilt, word) {
|
||||
const player = this.players[playerIdx];
|
||||
if (!this._takeFromHand(player, letter)) return { type: 'noop' };
|
||||
this.center.built = newBuilt;
|
||||
this.center.cards = newBuilt.split('');
|
||||
this.center.ownerIdx = playerIdx;
|
||||
this.center.targetWord = word;
|
||||
this.consecPasses = 0;
|
||||
|
||||
// The placed letter may itself finish the declared word — you complete and
|
||||
// bank your own word in one move (the classic Kiitos grab).
|
||||
if (newBuilt === word) {
|
||||
const evt = this._complete(playerIdx);
|
||||
return { ...evt, type: 'deviate', letter, built: newBuilt, word, completed: true };
|
||||
}
|
||||
this._advanceTurn();
|
||||
return { type: 'deviate', playerIdx, letter, built: newBuilt, word, completed: false };
|
||||
}
|
||||
|
||||
// No legal move. When every player passes in a row we resolve the stuck centre.
|
||||
applyPass(playerIdx = this.turnIdx) {
|
||||
this.consecPasses++;
|
||||
const evt = { type: 'pass', playerIdx, resolved: null, roundOver: false };
|
||||
if (this.consecPasses >= this.n) {
|
||||
if (this.center.built) {
|
||||
// Abandoned word: its owner is charged the centre cards as a penalty.
|
||||
const owner = this.players[this.center.ownerIdx];
|
||||
owner.negative.push(...this.center.cards);
|
||||
evt.resolved = { ownerIdx: this.center.ownerIdx, cards: this.center.cards.length };
|
||||
this._clearCenter();
|
||||
this.consecPasses = 0;
|
||||
this.redrawAll();
|
||||
} else {
|
||||
// Nothing on the table and nobody can act → the round is over.
|
||||
this.roundOver = true;
|
||||
evt.roundOver = true;
|
||||
}
|
||||
}
|
||||
this._advanceTurn();
|
||||
return evt;
|
||||
}
|
||||
|
||||
_complete(completerIdx) {
|
||||
const owner = this.players[this.center.ownerIdx];
|
||||
const cards = this.center.cards.slice();
|
||||
owner.positive.push(...cards);
|
||||
const evt = {
|
||||
type: 'play', playerIdx: completerIdx, letter: cards[cards.length - 1],
|
||||
completed: true, ownerIdx: this.center.ownerIdx, word: this.center.targetWord,
|
||||
cards: cards.length,
|
||||
};
|
||||
const ownerIdx = this.center.ownerIdx;
|
||||
this._clearCenter();
|
||||
this.consecPasses = 0;
|
||||
this.redrawAll();
|
||||
this.turnIdx = ownerIdx; // the owner leads the next word
|
||||
return evt;
|
||||
}
|
||||
|
||||
_clearCenter() {
|
||||
this.center = { built: '', cards: [], ownerIdx: -1, targetWord: null };
|
||||
}
|
||||
|
||||
// ── Scoring ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Tally this round into match totals, applying the active points-per-card.
|
||||
scoreRound() {
|
||||
const mult = this.config.pointsPerCard;
|
||||
for (const p of this.players) {
|
||||
p.roundScore = (p.positive.length - p.negative.length) * mult;
|
||||
p.total += p.roundScore;
|
||||
}
|
||||
}
|
||||
|
||||
isMatchOver() { return this.round >= TOTAL_ROUNDS; }
|
||||
|
||||
// Move to the next round (caller checks isMatchOver first).
|
||||
advanceRound() {
|
||||
if (this.isMatchOver()) { this.finished = true; return; }
|
||||
this.startRound(this.round + 1);
|
||||
}
|
||||
|
||||
standings() {
|
||||
return [...this.players].sort((a, b) => b.total - a.total);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,6 +57,7 @@ import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
|
|||
import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
|
||||
import FarkelGame from './games/farkel/FarkelGame.js';
|
||||
import StrategoGame from './games/stratego/StrategoGame.js';
|
||||
import KiitosGame from './games/kiitos/KiitosGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -127,6 +128,7 @@ const config = {
|
|||
VideoPokerGame,
|
||||
FarkelGame,
|
||||
StrategoGame,
|
||||
KiitosGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,16 @@ import { TutorialModal } from '../ui/TutorialModal.js';
|
|||
|
||||
const CATEGORIES = [
|
||||
{ key: 'tabletop', label: 'Tabletop' },
|
||||
{ key: 'cards', label: 'Cards' },
|
||||
{ key: 'cards', label: 'Cards & Dice' },
|
||||
{ key: 'casino', label: 'Casino' },
|
||||
{ key: 'word', label: 'Word' },
|
||||
{ key: 'word', label: 'Words & Numbers' },
|
||||
];
|
||||
|
||||
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3 };
|
||||
const ICON_INACTIVE = 56;
|
||||
const ICON_ACTIVE = 72;
|
||||
const ICON_OVERSHOOT = 86;
|
||||
const ICON_X_OFFSET = -125;
|
||||
const ICON_X_OFFSET = -145;
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
constructor() { super('GameMenu'); }
|
||||
|
|
@ -59,14 +59,15 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._tabs = {};
|
||||
|
||||
const activeCats = CATEGORIES.filter(({ key }) => this._gamesByCategory[key].length > 0);
|
||||
const tabSpacing = Math.min(380, 1600 / activeCats.length);
|
||||
const tabSpacing = Math.min(420, 1800 / activeCats.length);
|
||||
const tabStartX = cx - (tabSpacing * (activeCats.length - 1)) / 2;
|
||||
|
||||
activeCats.forEach(({ key, label }, i) => {
|
||||
const btn = new Button(this, tabStartX + i * tabSpacing, 230, label, () => this.showCategory(key), {
|
||||
width: 320,
|
||||
width: 390,
|
||||
variant: 'ghost',
|
||||
});
|
||||
btn.text.setX(40);
|
||||
this._tabs[key] = btn;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -72,3 +72,4 @@ registerGame({ slug: 'labyrinth', name: 'Labyrinth', category: '
|
|||
registerGame({ slug: 'videopoker', name: 'Video Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 44 });
|
||||
registerGame({ slug: 'farkel', name: 'Farkle', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 45 });
|
||||
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
|
||||
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
// Kiitos engine: dictionary word-set + prefix trie, validation, and AI move choice.
|
||||
// Pure logic — no Express. Initialized once at server start from the ENABLE list.
|
||||
//
|
||||
// Game shape (see public/src/games/kiitos/KiitosLogic.js for the full state model):
|
||||
// A shared centre sequence is built one letter at a time toward an owner's
|
||||
// declared target word. On your turn you MUST play the next required letter if
|
||||
// you hold it; otherwise you may "deviate" — play a letter that repurposes the
|
||||
// sequence toward a new word you declare. This engine answers the dictionary
|
||||
// questions (is this a word? does this prefix lead anywhere? what should the AI
|
||||
// play?) that the pure logic layer deliberately does not embed.
|
||||
|
||||
const ABS_MIN = 4; // shortest legal Kiitos word (round 1 minimum)
|
||||
const MAX_LEN = 12; // cap word length so play stays snappy
|
||||
|
||||
let root = null; // trie root: { word: boolean, kids: Map<char, node> }
|
||||
let wordSet = null; // Set<string> of every legal Kiitos word
|
||||
|
||||
const completionCache = new Map(); // `${prefix}|${minLen}` -> boolean
|
||||
const wordCache = new Map(); // `${prefix}|${minLen}` -> string|null
|
||||
|
||||
function makeNode() { return { word: false, kids: new Map() }; }
|
||||
|
||||
export function initKiitosDictionary(words) {
|
||||
root = makeNode();
|
||||
wordSet = new Set();
|
||||
completionCache.clear();
|
||||
wordCache.clear();
|
||||
for (const raw of words) {
|
||||
const w = String(raw).toUpperCase();
|
||||
if (w.length < ABS_MIN || w.length > MAX_LEN || !/^[A-Z]+$/.test(w)) continue;
|
||||
wordSet.add(w);
|
||||
let node = root;
|
||||
for (const ch of w) {
|
||||
let next = node.kids.get(ch);
|
||||
if (!next) { next = makeNode(); node.kids.set(ch, next); }
|
||||
node = next;
|
||||
}
|
||||
node.word = true;
|
||||
}
|
||||
return { words: wordSet.size };
|
||||
}
|
||||
|
||||
export const MIN_WORD_LEN = ABS_MIN;
|
||||
|
||||
// ── Lookups ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function nodeFor(prefix) {
|
||||
let n = root;
|
||||
for (const ch of prefix) {
|
||||
if (!n) return null;
|
||||
n = n.kids.get(ch);
|
||||
}
|
||||
return n ?? null;
|
||||
}
|
||||
|
||||
// Does the subtree rooted at n contain any word marker (n itself counts)?
|
||||
function subtreeHasWord(n) {
|
||||
if (!n) return false;
|
||||
if (n.word) return true;
|
||||
for (const c of n.kids.values()) if (subtreeHasWord(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Does the subtree contain a word whose total length is >= prefixLen + depth?
|
||||
function subtreeHasWordAtDepth(n, depth) {
|
||||
if (!n) return false;
|
||||
if (depth <= 0) return subtreeHasWord(n);
|
||||
for (const c of n.kids.values()) if (subtreeHasWordAtDepth(c, depth - 1)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isValidKiitosWord(word, minLen = ABS_MIN) {
|
||||
if (!wordSet) return false;
|
||||
const s = String(word).toUpperCase();
|
||||
return s.length >= minLen && wordSet.has(s);
|
||||
}
|
||||
|
||||
// Is `prefix` the start of at least one legal word of length >= minLen?
|
||||
export function hasCompletion(prefix, minLen = ABS_MIN) {
|
||||
const key = `${prefix}|${minLen}`;
|
||||
const cached = completionCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const n = nodeFor(prefix);
|
||||
const ok = n ? subtreeHasWordAtDepth(n, minLen - prefix.length) : false;
|
||||
completionCache.set(key, ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Shallowest legal word (length >= minLen) that starts with `prefix`, or null.
|
||||
export function findWord(prefix, minLen = ABS_MIN) {
|
||||
const key = `${prefix}|${minLen}`;
|
||||
const cached = wordCache.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const start = nodeFor(prefix);
|
||||
let result = null;
|
||||
if (start) {
|
||||
let frontier = [[start, prefix]];
|
||||
while (frontier.length && !result) {
|
||||
const next = [];
|
||||
for (const [n, s] of frontier) {
|
||||
if (n.word && s.length >= minLen) { result = s; break; }
|
||||
if (s.length < MAX_LEN) for (const [ch, c] of n.kids) next.push([c, s + ch]);
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
}
|
||||
wordCache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── AI move selection ─────────────────────────────────────────────────────────
|
||||
// Returns one of:
|
||||
// { type: 'play', letter, forced: true } — mandatory next letter
|
||||
// { type: 'deviate', letter, prefix, word } — repurpose / start a word
|
||||
// { type: 'pass' } — no legal move
|
||||
//
|
||||
// params: { hand:[letters], built, targetWord, minLen, skill, allowPrepend, superKiitos }
|
||||
|
||||
function letterCounts(letters) {
|
||||
const m = Object.create(null);
|
||||
for (const c of letters) m[c] = (m[c] || 0) + 1;
|
||||
return m;
|
||||
}
|
||||
|
||||
function weightedPick(items, weights) {
|
||||
let total = 0;
|
||||
for (const w of weights) total += w;
|
||||
if (total <= 0) return items[Math.floor(Math.random() * items.length)];
|
||||
let roll = Math.random() * total;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
roll -= weights[i];
|
||||
if (roll <= 0) return items[i];
|
||||
}
|
||||
return items[items.length - 1];
|
||||
}
|
||||
|
||||
export function chooseMove({
|
||||
hand, built = '', targetWord = null, minLen = ABS_MIN,
|
||||
skill = 3, allowPrepend = false, superKiitos = false,
|
||||
}) {
|
||||
const H = hand.map((c) => String(c).toUpperCase());
|
||||
|
||||
// 1. Forced play: holding the next required letter is mandatory.
|
||||
if (targetWord && built.length < targetWord.length) {
|
||||
const need = targetWord[built.length];
|
||||
if (H.includes(need)) return { type: 'play', letter: need, forced: true };
|
||||
}
|
||||
|
||||
// 2. Deviate / start: every letter-and-placement that leads to a real word.
|
||||
const handCounts = letterCounts(H);
|
||||
const seen = new Set();
|
||||
const cands = [];
|
||||
const consider = (prefix, letter) => {
|
||||
if (seen.has(prefix)) return;
|
||||
seen.add(prefix);
|
||||
if (!hasCompletion(prefix, minLen)) return;
|
||||
const word = findWord(prefix, minLen);
|
||||
if (word) cands.push({ letter, prefix, word });
|
||||
};
|
||||
for (const L of new Set(H)) {
|
||||
consider(built + L, L);
|
||||
if (allowPrepend) consider(L + built, L);
|
||||
if (superKiitos) {
|
||||
for (let i = 0; i <= built.length; i++) consider(built.slice(0, i) + L + built.slice(i), L);
|
||||
}
|
||||
}
|
||||
if (!cands.length) return { type: 'pass' };
|
||||
|
||||
// Score each candidate. The prize move is one that completes a word right now;
|
||||
// failing that, favour words that are close to done and that the AI can help
|
||||
// finish from its own hand. Higher skill sharpens these preferences and adds a
|
||||
// mild taste for longer (higher-scoring) words.
|
||||
const k = Math.max(1, Math.min(5, skill | 0));
|
||||
const weights = cands.map((c) => {
|
||||
const wordCounts = letterCounts(c.word.split(''));
|
||||
const prefixCounts = letterCounts(c.prefix.split(''));
|
||||
const handAfter = { ...handCounts };
|
||||
handAfter[c.letter] = (handAfter[c.letter] || 0) - 1;
|
||||
let need = 0, have = 0;
|
||||
for (const ch of Object.keys(wordCounts)) {
|
||||
const remaining = Math.max(0, wordCounts[ch] - (prefixCounts[ch] || 0));
|
||||
need += remaining;
|
||||
have += Math.min(remaining, Math.max(0, handAfter[ch] || 0));
|
||||
}
|
||||
if (need === 0) return 40 + c.word.length; // immediate completion — grab it
|
||||
const selfFrac = have / need;
|
||||
const closeness = 1 / (1 + need); // fewer letters left is better
|
||||
const base = closeness * (0.3 + selfFrac);
|
||||
return Math.pow(base, 0.6 + k * 0.25) * Math.pow(c.word.length, (k - 1) * 0.04);
|
||||
});
|
||||
|
||||
const pick = weightedPick(cands, weights);
|
||||
return { type: 'deviate', letter: pick.letter, prefix: pick.prefix, word: pick.word };
|
||||
}
|
||||
|
|
@ -21,6 +21,10 @@ import { generatePuzzle as tectonicGenerate } from './tectonicEngine.js';
|
|||
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
|
||||
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
|
||||
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.js';
|
||||
import {
|
||||
initKiitosDictionary, isValidKiitosWord, hasCompletion as kiitosHasCompletion,
|
||||
findWord as kiitosFindWord, chooseMove as kiitosChooseMove, MIN_WORD_LEN as KIITOS_MIN,
|
||||
} from './kiitosEngine.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
|
||||
|
|
@ -161,6 +165,11 @@ function loadWordLists() {
|
|||
const crosswordStats = initMiniCrosswordPuzzles();
|
||||
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
|
||||
|
||||
// Kiitos: every ENABLE word of length 4–12 (the build-a-word card game).
|
||||
const kiitosWords = allWords.filter(w => w.length >= 4 && w.length <= 12 && /^[A-Z]+$/.test(w));
|
||||
const kiitosStats = initKiitosDictionary(kiitosWords);
|
||||
console.log(`[words] loaded ${kiitosStats.words} Kiitos words (4–12 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));
|
||||
|
|
@ -254,6 +263,45 @@ router.post('/scrabble/ai-move', (req, res) => {
|
|||
res.json(move);
|
||||
});
|
||||
|
||||
// ── Kiitos ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/words/kiitos/validate { word, minLen?, prefix? }
|
||||
// Confirms a declared target word is a real Kiitos word (>= minLen). When `prefix`
|
||||
// is supplied it must also be the start of that word — used to validate the human's
|
||||
// deviation (the new centre sequence must lead to the word they announce).
|
||||
router.post('/kiitos/validate', (req, res) => {
|
||||
const word = String(req.body?.word ?? '').trim().toUpperCase();
|
||||
const minLen = Number(req.body?.minLen) || KIITOS_MIN;
|
||||
const prefix = String(req.body?.prefix ?? '').trim().toUpperCase();
|
||||
const valid = isValidKiitosWord(word, minLen) && (!prefix || word.startsWith(prefix));
|
||||
res.json({ valid });
|
||||
});
|
||||
|
||||
// POST /api/words/kiitos/prefix { prefix, minLen? }
|
||||
// Whether the sequence so far can still reach a legal word, plus one example word
|
||||
// (used to gate the human's letter plays and offer a hint).
|
||||
router.post('/kiitos/prefix', (req, res) => {
|
||||
const prefix = String(req.body?.prefix ?? '').trim().toUpperCase();
|
||||
const minLen = Number(req.body?.minLen) || KIITOS_MIN;
|
||||
res.json({ ok: kiitosHasCompletion(prefix, minLen), word: kiitosFindWord(prefix, minLen) });
|
||||
});
|
||||
|
||||
// POST /api/words/kiitos/ai-move { hand, built, targetWord, minLen, skill, allowPrepend, superKiitos }
|
||||
// Returns the AI's chosen move: a forced play, a deviation, or a pass.
|
||||
router.post('/kiitos/ai-move', (req, res) => {
|
||||
const b = req.body ?? {};
|
||||
if (!Array.isArray(b.hand)) return res.status(400).json({ error: 'hand is required' });
|
||||
res.json(kiitosChooseMove({
|
||||
hand: b.hand,
|
||||
built: String(b.built ?? ''),
|
||||
targetWord: b.targetWord ?? null,
|
||||
minLen: Number(b.minLen) || KIITOS_MIN,
|
||||
skill: Number(b.skill) || 3,
|
||||
allowPrepend: !!b.allowPrepend,
|
||||
superKiitos: !!b.superKiitos,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Ghost ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// POST /api/words/ghost/judge { fragment: string }
|
||||
|
|
|
|||
Loading…
Reference in New Issue