diff --git a/public/src/games/kiitos/KiitosAI.js b/public/src/games/kiitos/KiitosAI.js new file mode 100644 index 0000000..15f9ee6 --- /dev/null +++ b/public/src/games/kiitos/KiitosAI.js @@ -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; +} diff --git a/public/src/games/kiitos/KiitosData.js b/public/src/games/kiitos/KiitosData.js new file mode 100644 index 0000000..76ef485 --- /dev/null +++ b/public/src/games/kiitos/KiitosData.js @@ -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; diff --git a/public/src/games/kiitos/KiitosGame.js b/public/src/games/kiitos/KiitosGame.js new file mode 100644 index 0000000..7d84104 --- /dev/null +++ b/public/src/games/kiitos/KiitosGame.js @@ -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 */ } + } +} diff --git a/public/src/games/kiitos/KiitosLogic.js b/public/src/games/kiitos/KiitosLogic.js new file mode 100644 index 0000000..c2c89c0 --- /dev/null +++ b/public/src/games/kiitos/KiitosLogic.js @@ -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); + } +} diff --git a/public/src/main.js b/public/src/main.js index b4b8cce..e27a9c8 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameMenuScene.js b/public/src/scenes/GameMenuScene.js index c7008d1..88d9813 100644 --- a/public/src/scenes/GameMenuScene.js +++ b/public/src/scenes/GameMenuScene.js @@ -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; }); diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index ae3a0f1..2bc3af2 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/server/games/registry.js b/server/games/registry.js index aaae9b7..1d69b46 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -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 }); diff --git a/server/words/kiitosEngine.js b/server/words/kiitosEngine.js new file mode 100644 index 0000000..e6a3495 --- /dev/null +++ b/server/words/kiitosEngine.js @@ -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 } +let wordSet = null; // Set 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 }; +} diff --git a/server/words/wordRoutes.js b/server/words/wordRoutes.js index d67e30e..3de66bf 100644 --- a/server/words/wordRoutes.js +++ b/server/words/wordRoutes.js @@ -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 }