feat: enhance craps UI and improve ghost AI skill scaling
- Replace Righteous font with JqkasWild for craps title, puck, and number labels - Refactor zone label rendering into dedicated methods for cleaner code - Add payout information display on craps number boxes (6→SIX, 9→NINE) - Redesign Field zone with styled number circles (accent for 2/12) - Update Don't Pass/Don't Come zones with bar text and red accent borders - Add common words trie for Ghost AI to target on skill ≤3 - Make lower-skill Ghost AI play more human-like by preferring common word continuations - Fallback to off-dictionary play when no common word path available
This commit is contained in:
parent
ea135b35ed
commit
e3fb356520
Binary file not shown.
|
|
@ -126,7 +126,7 @@ export default class CrapsGame extends Phaser.Scene {
|
||||||
|
|
||||||
buildTitle() {
|
buildTitle() {
|
||||||
this.add.text(CX, 52, 'Craps', {
|
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);
|
}).setOrigin(0.5).setDepth(D.ui);
|
||||||
|
|
||||||
this.statusText = this.add.text(CX, 96, '', {
|
this.statusText = this.add.text(CX, 96, '', {
|
||||||
|
|
@ -146,22 +146,29 @@ export default class CrapsGame extends Phaser.Scene {
|
||||||
const startX = CX - totalW / 2;
|
const startX = CX - totalW / 2;
|
||||||
const boxY = 150;
|
const boxY = 150;
|
||||||
BOX_NUMS.forEach((n, i) => {
|
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;
|
this._boxY = boxY; this._boxH = boxH;
|
||||||
|
|
||||||
// Don't Come (left of the boxes)
|
// 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);
|
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
|
// Come
|
||||||
add('come', BET.COME, null, 'COME', startX, 268, totalW, 88);
|
add('come', BET.COME, null, 'COME', startX, 268, totalW, 88);
|
||||||
|
|
||||||
// Field
|
// Field
|
||||||
add('field', BET.FIELD, null, 'FIELD 2 3 4 9 10 11 12', TABLE.x + 60, 372, TABLE.w - 120, 86,
|
add('field', BET.FIELD, null, 'FIELD', TABLE.x + 60, 372, TABLE.w - 120, 86,
|
||||||
'2 pays double · 12 pays triple');
|
'2 pays double · 12 pays triple');
|
||||||
|
this.zones.field.fieldNums = [2, 3, 4, 9, 10, 11, 12];
|
||||||
|
|
||||||
// Don't Pass bar
|
// 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
|
// Pass line
|
||||||
add('pass', BET.PASS, null, 'PASS LINE', TABLE.x + 100, 552, TABLE.w - 200, 76);
|
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('pointerout', () => this.hoverZone(key, false));
|
||||||
zone.on('pointerup', () => this.onZoneClick(key));
|
zone.on('pointerup', () => this.onZoneClick(key));
|
||||||
|
|
||||||
const big = z.number != null;
|
this._renderZoneLabel(key, z);
|
||||||
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,
|
|
||||||
}).setOrigin(0.5).setDepth(D.zoneLabel);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_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) {
|
drawZones(hoverKey = null) {
|
||||||
|
|
@ -208,10 +295,19 @@ export default class CrapsGame extends Phaser.Scene {
|
||||||
for (const [key, z] of Object.entries(this.zones)) {
|
for (const [key, z] of Object.entries(this.zones)) {
|
||||||
const { x, y, w, h } = z.rect;
|
const { x, y, w, h } = z.rect;
|
||||||
const isLegal = !this.gs || legal.has(z.type);
|
const isLegal = !this.gs || legal.has(z.type);
|
||||||
|
const isDont = key === 'dontpass' || key === 'dontcome';
|
||||||
g.fillStyle(0x0c2e19, isLegal ? 0.5 : 0.18);
|
g.fillStyle(0x0c2e19, isLegal ? 0.5 : 0.18);
|
||||||
g.fillRoundedRect(x, y, w, h, 10);
|
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);
|
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() {
|
buildTooltip() {
|
||||||
const bg = this.add.graphics();
|
const bg = this.add.graphics();
|
||||||
const title = this.add.text(0, 0, '', {
|
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, '', {
|
const desc = this.add.text(0, 0, '', {
|
||||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
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.puck.add(g);
|
||||||
this.puckGfx = g;
|
this.puckGfx = g;
|
||||||
this.puckText = this.add.text(0, 0, 'OFF', {
|
this.puckText = this.add.text(0, 0, 'OFF', {
|
||||||
fontFamily: 'Righteous', fontSize: '18px', color: '#1a1208',
|
fontFamily: 'JqkasWild', fontSize: '18px', color: '#1a1208',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
this.puck.add(this.puckText);
|
this.puck.add(this.puckText);
|
||||||
this.drawPuck(false);
|
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.
|
// Place a chip in the left/right side of a zone, skirting the center text.
|
||||||
chipSidePos(z, zoneKey, i, n, pi) {
|
chipSidePos(z, zoneKey, i, n, pi) {
|
||||||
// Approximate widths of each zone's center text label.
|
// 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 margin = 28;
|
||||||
const jitter = this.seatJitter[pi % this.seatJitter.length];
|
const jitter = this.seatJitter[pi % this.seatJitter.length];
|
||||||
const leftBound = z.rect.x + margin;
|
const leftBound = z.rect.x + margin;
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JqkasWild';
|
||||||
|
src: url('/assets/fonts/JqkasWild.ttf') format('truetype');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
const MIN_LEN = 4;
|
const MIN_LEN = 4;
|
||||||
|
|
||||||
let TRIE = null;
|
let TRIE = null;
|
||||||
|
let COMMON_TRIE = null;
|
||||||
|
|
||||||
export function initGhostDictionary(words) {
|
export function initGhostDictionary(words) {
|
||||||
TRIE = { children: Object.create(null), terminal: false, loss: false };
|
TRIE = { children: Object.create(null), terminal: false, loss: false };
|
||||||
|
|
@ -26,6 +27,19 @@ export function initGhostDictionary(words) {
|
||||||
computeLoss(TRIE);
|
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() {
|
export function dictionaryReady() {
|
||||||
return TRIE !== null;
|
return TRIE !== null;
|
||||||
}
|
}
|
||||||
|
|
@ -44,8 +58,8 @@ function computeLoss(node) {
|
||||||
node.loss = !win;
|
node.loss = !win;
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeFor(fragment) {
|
function nodeFor(fragment, trie = TRIE) {
|
||||||
let node = TRIE;
|
let node = trie;
|
||||||
for (const ch of fragment) {
|
for (const ch of fragment) {
|
||||||
node = node.children[ch];
|
node = node.children[ch];
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
|
|
@ -86,19 +100,36 @@ export function chooseLetter(fragment, skill) {
|
||||||
const node = nodeFor(F);
|
const node = nodeFor(F);
|
||||||
if (!node) return { letter: null, isWord: false, isPrefix: false };
|
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 };
|
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 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;
|
let letter;
|
||||||
if (safe.length === 0) {
|
if (safe.length === 0) {
|
||||||
// Forced: every legal letter completes a word -> the AI self-loses this turn.
|
// Forced: every candidate letter completes a word -> the AI self-loses this turn.
|
||||||
letter = randomFrom(legal);
|
letter = randomFrom(candidates);
|
||||||
} else if (Math.random() < profile.blunder) {
|
} else if (Math.random() < profile.blunder) {
|
||||||
// Careless: pick any legal letter, which may accidentally complete a word.
|
// Careless: pick any candidate letter, which may accidentally complete a word.
|
||||||
letter = randomFrom(legal);
|
letter = randomFrom(candidates);
|
||||||
} else {
|
} else {
|
||||||
// Careful: never complete a word; use foresight to hand off a losing position.
|
// Careful: never complete a word; use foresight to hand off a losing position.
|
||||||
const winning = safe.filter(L => node.children[L].loss);
|
const winning = safe.filter(L => node.children[L].loss);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { initScrabbleDictionary, isValidWord, chooseMove } from './scrabbleEngine.js';
|
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 {
|
import {
|
||||||
initWordLadderDictionary,
|
initWordLadderDictionary,
|
||||||
initCommonWordSets as ladderInitCommon,
|
initCommonWordSets as ladderInitCommon,
|
||||||
|
|
@ -184,6 +184,9 @@ function loadWordLists() {
|
||||||
const common4 = commonWords.filter(w => /^[A-Z]{4}$/.test(w));
|
const common4 = commonWords.filter(w => /^[A-Z]{4}$/.test(w));
|
||||||
ladderInitCommon(common3, common4);
|
ladderInitCommon(common3, common4);
|
||||||
console.log(`[words] loaded Word Ladder common words (${common3.length} 3-letter, ${common4.length} 4-letter)`);
|
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) {
|
} catch (err) {
|
||||||
console.warn('[words] common.txt not found; Kiitos AI will use full ENABLE list');
|
console.warn('[words] common.txt not found; Kiitos AI will use full ENABLE list');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue