diff --git a/public/assets/fonts/JqkasWild.ttf b/public/assets/fonts/JqkasWild.ttf new file mode 100644 index 0000000..7fadc69 Binary files /dev/null and b/public/assets/fonts/JqkasWild.ttf differ diff --git a/public/src/games/craps/CrapsGame.js b/public/src/games/craps/CrapsGame.js index 26c5999..bae7a63 100644 --- a/public/src/games/craps/CrapsGame.js +++ b/public/src/games/craps/CrapsGame.js @@ -126,7 +126,7 @@ export default class CrapsGame extends Phaser.Scene { buildTitle() { this.add.text(CX, 52, 'Craps', { - fontFamily: 'Righteous', fontSize: '50px', color: COLORS.textHex, + fontFamily: 'JqkasWild', fontSize: '50px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.ui); this.statusText = this.add.text(CX, 96, '', { @@ -146,22 +146,29 @@ export default class CrapsGame extends Phaser.Scene { const startX = CX - totalW / 2; const boxY = 150; BOX_NUMS.forEach((n, i) => { - add(`box${n}`, BET.PLACE, n, String(n), startX + i * (boxW + gap), boxY, boxW, boxH); + const lbl = n === 6 ? 'SIX' : n === 9 ? 'NINE' : String(n); + const payout = (n === 4 || n === 10) ? 'PAYS 9 TO 5' + : (n === 5 || n === 9) ? 'PAYS 7 TO 5' : 'PAYS 7 TO 6'; + add(`box${n}`, BET.PLACE, n, lbl, startX + i * (boxW + gap), boxY, boxW, boxH); + this.zones[`box${n}`].payout = payout; }); this._boxY = boxY; this._boxH = boxH; // Don't Come (left of the boxes) add('dontcome', BET.DONT_COME, null, "DON'T\nCOME", TABLE.x + 24, boxY, startX - TABLE.x - 44, boxH * 2 + 14); + this.zones.dontcome.barText = 'BAR 12'; // Come add('come', BET.COME, null, 'COME', startX, 268, totalW, 88); // Field - add('field', BET.FIELD, null, 'FIELD 2 3 4 9 10 11 12', TABLE.x + 60, 372, TABLE.w - 120, 86, - '2 pays double · 12 pays triple'); + add('field', BET.FIELD, null, 'FIELD', TABLE.x + 60, 372, TABLE.w - 120, 86, + '2 pays double · 12 pays triple'); + this.zones.field.fieldNums = [2, 3, 4, 9, 10, 11, 12]; // Don't Pass bar - add('dontpass', BET.DONT_PASS, null, "DON'T PASS BAR", TABLE.x + 140, 476, TABLE.w - 280, 60); + add('dontpass', BET.DONT_PASS, null, "DON'T PASS", TABLE.x + 140, 476, TABLE.w - 280, 60); + this.zones.dontpass.barText = 'BAR 12'; // Pass line add('pass', BET.PASS, null, 'PASS LINE', TABLE.x + 100, 552, TABLE.w - 200, 76); @@ -187,18 +194,98 @@ export default class CrapsGame extends Phaser.Scene { zone.on('pointerout', () => this.hoverZone(key, false)); zone.on('pointerup', () => this.onZoneClick(key)); - const big = z.number != null; - this.add.text(z.center.x, z.center.y, z.label, { - fontFamily: big ? 'Righteous' : '"Julius Sans One"', - fontSize: big ? '40px' : '22px', - color: COLORS.textHex, align: 'center', - }).setOrigin(0.5).setDepth(D.zoneLabel); - if (z.sub) { - this.add.text(z.center.x, z.rect.y + z.rect.h - 16, z.sub, { - fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, + this._renderZoneLabel(key, z); + } + } + + _renderZoneLabel(key, z) { + if (z.number != null) return this._renderNumberBox(z); + if (key === 'field') return this._renderFieldZone(z); + if (key === 'dontpass') return this._renderDontZone(z, 21, 9); + if (key === 'dontcome') return this._renderDontComeZone(z); + if (key === 'pass') return this._renderLineZone(z, 'PASS LINE', 34); + if (key === 'come') return this._renderLineZone(z, 'COME', 32); + } + + _renderNumberBox(z) { + this.add.text(z.center.x, z.center.y - 16, z.label, { + fontFamily: 'JqkasWild', fontSize: '38px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); + this.add.text(z.center.x, z.center.y + 24, z.payout, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.accentHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); + } + + _renderLineZone(z, text, fontSize) { + this.add.text(z.center.x, z.center.y, text, { + fontFamily: '"Julius Sans One"', fontSize: `${fontSize}px`, color: COLORS.textHex, + letterSpacing: 14, + }).setOrigin(0.5).setDepth(D.zoneLabel); + } + + _renderDontZone(z, fontSize, ls) { + this.add.text(z.center.x, z.center.y - 11, "DON'T PASS", { + fontFamily: '"Julius Sans One"', fontSize: `${fontSize}px`, color: COLORS.textHex, + letterSpacing: ls, + }).setOrigin(0.5).setDepth(D.zoneLabel); + this.add.text(z.center.x, z.center.y + 17, z.barText, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); + } + + _renderDontComeZone(z) { + this.add.text(z.center.x, z.center.y - 44, "DON'T", { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); + this.add.text(z.center.x, z.center.y, 'COME', { + fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, + letterSpacing: 8, + }).setOrigin(0.5).setDepth(D.zoneLabel); + this.add.text(z.center.x, z.center.y + 44, z.barText, { + fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); + } + + _renderFieldZone(z) { + // "FIELD" label left-anchored + this.add.text(z.rect.x + 55, z.center.y, 'FIELD', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, + letterSpacing: 6, + }).setOrigin(0.5).setDepth(D.zoneLabel); + + // Vertical separator + const sep = this.add.graphics().setDepth(D.zoneLabel); + sep.lineStyle(1, COLORS.accent, 0.35); + sep.lineBetween(z.rect.x + 100, z.rect.y + 8, z.rect.x + 100, z.rect.y + z.rect.h - 8); + + // 7 number tokens, evenly spaced from x=430 to x=1630, step=200 + const fieldNums = z.fieldNums; + const startX = 430, step = 200; + fieldNums.forEach((n, i) => { + const nx = startX + i * step; + const ny = z.center.y; + if (n === 2 || n === 12) { + const cg = this.add.graphics().setDepth(D.zoneLabel); + cg.fillStyle(COLORS.accent, 0.85); + cg.fillCircle(nx, ny, 22); + cg.lineStyle(2, COLORS.gold, 1); + cg.strokeCircle(nx, ny, 22); + this.add.text(nx, ny, String(n), { + fontFamily: 'JqkasWild', fontSize: n === 12 ? '22px' : '22px', + color: COLORS.textDarkHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); + } else { + const fs = (n === 10 || n === 11) ? '24px' : '28px'; + this.add.text(nx, ny, String(n), { + fontFamily: 'JqkasWild', fontSize: fs, color: COLORS.textHex, }).setOrigin(0.5).setDepth(D.zoneLabel); } - } + }); + + // Sub-text at bottom of zone + this.add.text(z.center.x, z.rect.y + z.rect.h - 10, z.sub, { + fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.zoneLabel); } drawZones(hoverKey = null) { @@ -208,10 +295,19 @@ export default class CrapsGame extends Phaser.Scene { for (const [key, z] of Object.entries(this.zones)) { const { x, y, w, h } = z.rect; const isLegal = !this.gs || legal.has(z.type); + const isDont = key === 'dontpass' || key === 'dontcome'; g.fillStyle(0x0c2e19, isLegal ? 0.5 : 0.18); g.fillRoundedRect(x, y, w, h, 10); - g.lineStyle(key === hoverKey && isLegal ? 4 : 2, key === hoverKey && isLegal ? COLORS.gold : 0x3c8a52, isLegal ? 0.9 : 0.4); + const strokeColor = (key === hoverKey && isLegal) ? COLORS.gold + : isDont ? 0x8b2020 : 0x3c8a52; + const strokeAlpha = isLegal ? 0.9 : 0.4; + const strokeW = (key === hoverKey && isLegal) ? 4 : 2; + g.lineStyle(strokeW, strokeColor, strokeAlpha); g.strokeRoundedRect(x, y, w, h, 10); + if (z.number != null) { + g.fillStyle(COLORS.accent, isLegal ? 0.55 : 0.2); + g.fillRect(x + 10, y, w - 20, 4); + } } } @@ -231,7 +327,7 @@ export default class CrapsGame extends Phaser.Scene { buildTooltip() { const bg = this.add.graphics(); const title = this.add.text(0, 0, '', { - fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex, + fontFamily: 'JqkasWild', fontSize: '30px', color: COLORS.goldHex, }); const desc = this.add.text(0, 0, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex, @@ -293,7 +389,7 @@ export default class CrapsGame extends Phaser.Scene { this.puck.add(g); this.puckGfx = g; this.puckText = this.add.text(0, 0, 'OFF', { - fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208', + fontFamily: 'JqkasWild', fontSize: '18px', color: '#1a1208', }).setOrigin(0.5); this.puck.add(this.puckText); this.drawPuck(false); @@ -579,7 +675,7 @@ export default class CrapsGame extends Phaser.Scene { // Place a chip in the left/right side of a zone, skirting the center text. chipSidePos(z, zoneKey, i, n, pi) { // Approximate widths of each zone's center text label. - const avoidW = { pass: 250, dontpass: 310, field: 550, come: 170, dontcome: 60 }[zoneKey] ?? 240; + const avoidW = { pass: 250, dontpass: 310, field: 550, come: 200, dontcome: 60 }[zoneKey] ?? 240; const margin = 28; const jitter = this.seatJitter[pi % this.seatJitter.length]; const leftBound = z.rect.x + margin; diff --git a/public/styles.css b/public/styles.css index 7825eee..c73b3f8 100644 --- a/public/styles.css +++ b/public/styles.css @@ -19,6 +19,13 @@ font-style: normal; } +@font-face { + font-family: 'JqkasWild'; + src: url('/assets/fonts/JqkasWild.ttf') format('truetype'); + font-weight: normal; + font-style: normal; +} + html, body { margin: 0; padding: 0; diff --git a/server/words/ghostEngine.js b/server/words/ghostEngine.js index 7c25caf..e7b7ec5 100644 --- a/server/words/ghostEngine.js +++ b/server/words/ghostEngine.js @@ -12,6 +12,7 @@ const MIN_LEN = 4; let TRIE = null; +let COMMON_TRIE = null; export function initGhostDictionary(words) { TRIE = { children: Object.create(null), terminal: false, loss: false }; @@ -26,6 +27,19 @@ export function initGhostDictionary(words) { computeLoss(TRIE); } +export function initGhostCommonWords(words) { + COMMON_TRIE = { children: Object.create(null), terminal: false, loss: false }; + for (const w of words) { + if (w.length < MIN_LEN) continue; + let node = COMMON_TRIE; + for (const ch of w) { + node = node.children[ch] || (node.children[ch] = { children: Object.create(null), terminal: false, loss: false }); + } + node.terminal = true; + } + computeLoss(COMMON_TRIE); +} + export function dictionaryReady() { return TRIE !== null; } @@ -44,8 +58,8 @@ function computeLoss(node) { node.loss = !win; } -function nodeFor(fragment) { - let node = TRIE; +function nodeFor(fragment, trie = TRIE) { + let node = trie; for (const ch of fragment) { node = node.children[ch]; if (!node) return null; @@ -86,19 +100,36 @@ export function chooseLetter(fragment, skill) { const node = nodeFor(F); if (!node) return { letter: null, isWord: false, isPrefix: false }; - const legal = Object.keys(node.children); // every child is a valid prefix + const legal = Object.keys(node.children); // every child is a valid ENABLE prefix if (legal.length === 0) return { letter: null, isWord: false, isPrefix: false }; + // Skill <= 3: restrict candidates to letters that continue toward a common word. + // If no common-word continuation exists (human steered off the common path or + // none was available), the AI concedes by going off-dictionary. + // isWord / safe filtering always uses the authoritative ENABLE trie so the AI + // never accidentally complete a real word it thinks is "safe". + let candidates = legal; + if (skill <= 3 && COMMON_TRIE) { + const commonNode = nodeFor(F, COMMON_TRIE); + const commonSet = commonNode ? new Set(Object.keys(commonNode.children)) : new Set(); + const filtered = legal.filter(L => commonSet.has(L)); + if (filtered.length > 0) { + candidates = filtered; + } else { + return { letter: randomFrom(legal), isWord: false, isPrefix: false }; + } + } + const profile = SKILL[Math.max(1, Math.min(5, skill | 0))] ?? SKILL[3]; - const safe = legal.filter(L => !node.children[L].terminal); // don't complete a word + const safe = candidates.filter(L => !node.children[L].terminal); // don't complete a word let letter; if (safe.length === 0) { - // Forced: every legal letter completes a word -> the AI self-loses this turn. - letter = randomFrom(legal); + // Forced: every candidate letter completes a word -> the AI self-loses this turn. + letter = randomFrom(candidates); } else if (Math.random() < profile.blunder) { - // Careless: pick any legal letter, which may accidentally complete a word. - letter = randomFrom(legal); + // Careless: pick any candidate letter, which may accidentally complete a word. + letter = randomFrom(candidates); } else { // Careful: never complete a word; use foresight to hand off a losing position. const winning = safe.filter(L => node.children[L].loss); diff --git a/server/words/wordRoutes.js b/server/words/wordRoutes.js index 4f65530..a4ef4bc 100644 --- a/server/words/wordRoutes.js +++ b/server/words/wordRoutes.js @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js'; -import { initGhostDictionary, judge as ghostJudge, chooseLetter as ghostChooseLetter, suggestWords as ghostSuggestWords } from './ghostEngine.js'; +import { initGhostDictionary, initGhostCommonWords, judge as ghostJudge, chooseLetter as ghostChooseLetter, suggestWords as ghostSuggestWords } from './ghostEngine.js'; import { initWordLadderDictionary, initCommonWordSets as ladderInitCommon, @@ -184,6 +184,9 @@ function loadWordLists() { const common4 = commonWords.filter(w => /^[A-Z]{4}$/.test(w)); ladderInitCommon(common3, common4); console.log(`[words] loaded Word Ladder common words (${common3.length} 3-letter, ${common4.length} 4-letter)`); + const ghostCommon = commonWords.filter(w => w.length >= 4 && /^[A-Z]+$/.test(w)); + initGhostCommonWords(ghostCommon); + console.log(`[words] loaded ${ghostCommon.length} Ghost common words for skill ≤3 AI targeting`); } catch (err) { console.warn('[words] common.txt not found; Kiitos AI will use full ENABLE list'); }