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:
Brian Fertig 2026-06-07 11:00:17 -06:00
parent dfd4950eab
commit 8a69710946
10 changed files with 1360 additions and 6 deletions

View File

@ -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;
}

View File

@ -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;

View File

@ -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 */ }
}
}

View File

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

View File

@ -57,6 +57,7 @@ import LabyrinthGame from './games/labyrinth/LabyrinthGame.js';
import VideoPokerGame from './games/videopoker/VideoPokerGame.js'; import VideoPokerGame from './games/videopoker/VideoPokerGame.js';
import FarkelGame from './games/farkel/FarkelGame.js'; import FarkelGame from './games/farkel/FarkelGame.js';
import StrategoGame from './games/stratego/StrategoGame.js'; import StrategoGame from './games/stratego/StrategoGame.js';
import KiitosGame from './games/kiitos/KiitosGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -127,6 +128,7 @@ const config = {
VideoPokerGame, VideoPokerGame,
FarkelGame, FarkelGame,
StrategoGame, StrategoGame,
KiitosGame,
], ],
}; };

View File

@ -8,16 +8,16 @@ import { TutorialModal } from '../ui/TutorialModal.js';
const CATEGORIES = [ const CATEGORIES = [
{ key: 'tabletop', label: 'Tabletop' }, { key: 'tabletop', label: 'Tabletop' },
{ key: 'cards', label: 'Cards' }, { key: 'cards', label: 'Cards & Dice' },
{ key: 'casino', label: 'Casino' }, { 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 TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3 };
const ICON_INACTIVE = 56; const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72; const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86; const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -125; const ICON_X_OFFSET = -145;
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } constructor() { super('GameMenu'); }
@ -59,14 +59,15 @@ export default class GameMenuScene extends Phaser.Scene {
this._tabs = {}; this._tabs = {};
const activeCats = CATEGORIES.filter(({ key }) => this._gamesByCategory[key].length > 0); 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; const tabStartX = cx - (tabSpacing * (activeCats.length - 1)) / 2;
activeCats.forEach(({ key, label }, i) => { activeCats.forEach(({ key, label }, i) => {
const btn = new Button(this, tabStartX + i * tabSpacing, 230, label, () => this.showCategory(key), { const btn = new Button(this, tabStartX + i * tabSpacing, 230, label, () => this.showCategory(key), {
width: 320, width: 390,
variant: 'ghost', variant: 'ghost',
}); });
btn.text.setX(40);
this._tabs[key] = btn; this._tabs[key] = btn;
}); });

View File

@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame' }; 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]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

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

View File

@ -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 };
}

View File

@ -21,6 +21,10 @@ import { generatePuzzle as tectonicGenerate } from './tectonicEngine.js';
import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js'; import { initBoggleDictionary, rollBoard, solveBoard } from './boggleEngine.js';
import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js'; import { initSpellingBeeDictionary, generatePuzzle as spellingBeeGenerate } from './spellingBeeEngine.js';
import { initMiniCrosswordPuzzles, getPuzzle as miniCrosswordGet } from './miniCrosswordEngine.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 __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt'); const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
@ -161,6 +165,11 @@ function loadWordLists() {
const crosswordStats = initMiniCrosswordPuzzles(); const crosswordStats = initMiniCrosswordPuzzles();
console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`); console.log(`[words] loaded ${crosswordStats.puzzles} Mini Crossword puzzles`);
// Kiitos: every ENABLE word of length 412 (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 (412 letters)`);
// Answer pool: prefer curated common words that are also in ENABLE; // Answer pool: prefer curated common words that are also in ENABLE;
// supplement with additional ENABLE words up to a healthy pool size. // supplement with additional ENABLE words up to a healthy pool size.
const curated = [...COMMON_WORDS].filter(w => enableFive.has(w)); const curated = [...COMMON_WORDS].filter(w => enableFive.has(w));
@ -254,6 +263,45 @@ router.post('/scrabble/ai-move', (req, res) => {
res.json(move); 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 ─────────────────────────────────────────────────────────────────── // ── Ghost ───────────────────────────────────────────────────────────────────
// POST /api/words/ghost/judge { fragment: string } // POST /api/words/ghost/judge { fragment: string }