fertig-classic-games/server/words/wordRoutes.js

207 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router } from 'express';
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 } from './ghostEngine.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const WORDLIST_PATH = path.join(__dirname, '../data/wordlists/enable1.txt');
// Common words used to build a player-friendly Wordle answer pool.
// These are well-known 5-letter English words that make fair Wordle puzzles.
const COMMON_WORDS = new Set([
'ABOUT','ABOVE','ABUSE','ACTOR','ACUTE','ADMIT','ADOPT','ADULT','AFTER',
'AGAIN','AGENT','AGREE','AHEAD','ALARM','ALBUM','ALERT','ALIGN','ALIVE',
'ALLEY','ALLOW','ALONE','ALONG','ALTER','ANGEL','ANGLE','ANGRY','ANKLE',
'ANNEX','ANNOY','APART','APPLE','APPLY','APRIL','ARENA','ARGUE','ARISE',
'ARMOR','ARRAY','ARROW','ASIDE','ASKED','ASSET','AVOID','AWARD','AWARE',
'AWFUL','BADLY','BAKER','BASIC','BASIS','BATCH','BEACH','BEGAN','BEGIN',
'BEING','BELOW','BENCH','BIBLE','BIRTH','BLACK','BLADE','BLAME','BLAND',
'BLANK','BLAST','BLAZE','BLEED','BLEND','BLESS','BLIND','BLOCK','BLOOD',
'BLOWN','BOARD','BONUS','BOOST','BOUND','BRAIN','BRAND','BRAVE','BREAD',
'BREAK','BREED','BRICK','BRIDE','BRIEF','BRING','BROAD','BROKE','BROOK',
'BROWN','BRUSH','BUILD','BUILT','BUNCH','BURNT','BUYER','CACHE','CANDY',
'CARGO','CARRY','CATCH','CAUSE','CHAIN','CHAIR','CHAOS','CHARM','CHASE',
'CHEAP','CHECK','CHEEK','CHEER','CHESS','CHEST','CHIEF','CHILD','CHINA',
'CIVIC','CIVIL','CLAIM','CLASS','CLEAN','CLEAR','CLERK','CLICK','CLIFF',
'CLIMB','CLING','CLOCK','CLOSE','CLOUD','COACH','COAST','COULD','COUNT',
'COURT','COVER','CRACK','CRAFT','CRASH','CRAZY','CREAM','CREEK','CRIME',
'CRISP','CROSS','CROWD','CROWN','CRUEL','CRUSH','CURVE','CYCLE','DAILY',
'DANCE','DEATH','DEBUT','DELAY','DENSE','DEPOT','DEPTH','DERBY','DEVIL',
'DIGIT','DIRTY','DOING','DOUBT','DOUGH','DRAFT','DRAIN','DRAMA','DRANK',
'DRAWN','DREAM','DRESS','DRIED','DRIFT','DRILL','DRINK','DRIVE','DROVE',
'DRUGS','DRUMS','DRUNK','DWELL','DYING','EAGER','EARLY','EARTH','EIGHT',
'ELECT','EMAIL','EMPTY','ENEMY','ENJOY','ENTER','ENTRY','EQUAL','ERROR',
'ESSAY','EVERY','EVENT','EXACT','EXIST','EXTRA','FAINT','FAIRY','FAITH',
'FALSE','FANCY','FAULT','FEAST','FENCE','FETCH','FEVER','FEWER','FIELD',
'FIFTH','FIFTY','FIGHT','FINAL','FLAIR','FLAME','FLASH','FLEET','FLESH',
'FLOAT','FLOOD','FLOOR','FLOUR','FLUID','FOCUS','FORCE','FORGE','FORUM',
'FOUND','FRANK','FRAUD','FRESH','FRONT','FROST','FROZE','FRUIT','FULLY',
'FUNDS','FUNNY','GHOST','GIANT','GIVEN','GLASS','GLOBE','GLOOM','GLOSS',
'GLOVE','GOING','GRACE','GRADE','GRAIN','GRAND','GRANT','GRASP','GRASS',
'GRAVE','GREAT','GREEN','GREET','GRIEF','GRIND','GROAN','GROSS','GROUP',
'GROVE','GROWN','GUARD','GUESS','GUEST','GUIDE','GUILT','GUISE','GUSTO',
'HABIT','HAPPY','HARSH','HEART','HEAVY','HENCE','HINGE','HONEY','HONOR',
'HORSE','HOTEL','HOUSE','HUMAN','HUMOR','HURRY','IDEAL','IMAGE','IMPLY',
'INBOX','INDEX','INNER','INPUT','INTER','INTRO','ISSUE','JAPAN','JOINT',
'JOUST','JUDGE','JUICE','JUICY','JUMBO','KARMA','KNIFE','KNOCK','KNOWN',
'LABEL','LARGE','LASER','LATER','LAUGH','LAYER','LEARN','LEASE','LEAVE',
'LEGAL','LEVEL','LIGHT','LIMIT','LINEN','LIVER','LOCAL','LODGE','LOGIC',
'LOOSE','LOVER','LOWER','LUCKY','LUNAR','LYING','MAGIC','MAJOR','MAKER',
'MARCH','MATCH','MAYOR','MEDIA','MERIT','METAL','MIGHT','MINOR','MINUS',
'MIXED','MODEL','MONEY','MONTH','MORAL','MOTOR','MOUNT','MOUSE','MOUTH',
'MOVED','MOVIE','MUSIC','NAIVE','NEVER','NIGHT','NOISE','NORTH','NOTED',
'NOVEL','NURSE','OCCUR','OFFER','OFTEN','ONSET','OPERA','ORBIT','ORDER',
'OTHER','OUTER','OUNCE','OWNER','PAINT','PANEL','PAPER','PARTY','PASTA',
'PATCH','PAUSE','PEACE','PEARL','PEDAL','PENNY','PERCH','PHASE','PHONE',
'PHOTO','PIANO','PIECE','PILOT','PITCH','PIXEL','PIZZA','PLACE','PLAIN',
'PLANE','PLANT','PLATE','PLAZA','PLEAD','PLUCK','PLUMB','PLUME','PLUMP',
'PLUNGE','POINT','POKER','POLAR','POUND','POWER','PRESS','PRICE','PRIDE',
'PRIME','PRINT','PRIOR','PRIZE','PROBE','PROOF','PROSE','PROUD','PROVE',
'PROXY','PULSE','PUPIL','QUEEN','QUERY','QUEST','QUEUE','QUICK','QUIET',
'QUITE','QUOTA','QUOTE','RADAR','RADIO','RAISE','RALLY','RANCH','RANGE',
'RAPID','RATIO','REACH','READY','REALM','REBEL','REFER','REIGN','RELAX',
'REPLY','RIGHT','RIGID','RISKY','RIVAL','RIVER','ROBOT','ROCKY','ROMAN',
'ROUGH','ROUND','ROUTE','ROYAL','RULER','RUMOR','RURAL','SAINT','SALAD',
'SAUCE','SCALE','SCALD','SCENE','SCOUT','SCREW','SEIZE','SENSE','SERVE',
'SEVEN','SHADE','SHAKE','SHALL','SHAME','SHAPE','SHARE','SHARP','SHELF',
'SHIFT','SHIRT','SHOCK','SHOOT','SHORT','SHOUT','SIGHT','SINCE','SIXTH',
'SIXTY','SIZED','SKILL','SLEEP','SLICE','SLIDE','SLOPE','SLUMP','SMALL',
'SMART','SMELL','SMILE','SMOKE','SOLAR','SOLID','SOLVE','SORRY','SOUND',
'SOUTH','SPACE','SPARK','SPEAK','SPELL','SPEND','SPICE','SPILL','SPINE',
'SPLIT','SPOKE','SPORT','SPRAY','SQUAD','STACK','STAFF','STAGE','STAIN',
'STAKE','STALE','STALL','STAND','START','STATE','STEAL','STEEL','STEEP',
'STICK','STILL','STOCK','STOOD','STORE','STORM','STORY','STRAP','STRAW',
'STRIP','STUDY','STUFF','STYLE','SUGAR','SUITE','SUPER','SURGE','SWAMP',
'SWEAR','SWEAT','SWEEP','SWEET','SWEPT','SWIFT','SWING','SWORE','SWORN',
'TABLE','TAKEN','TASTE','TEACH','TEETH','TENSE','TERMS','THANK','THEIR',
'THEME','THERE','THESE','THICK','THING','THINK','THIRD','THOSE','THREE',
'THREW','THROW','TIGHT','TIMER','TIRED','TITLE','TODAY','TOKEN','TONAL',
'TOPIC','TOTAL','TOUCH','TOUGH','TRACE','TRACK','TRADE','TRAIL','TRAIN',
'TRAIT','TRASH','TREAT','TREND','TRIAL','TRIBE','TRICK','TRIED','TROOP',
'TRUCK','TRULY','TRUMP','TRUNK','TRUTH','TUMOR','TWICE','TWIST','TYPICAL',
'ULTRA','UNDER','UNION','UNITE','UNITY','UNTIL','UPPER','UPSET','URBAN',
'USAGE','UTTER','VALID','VALUE','VALVE','VERSE','VIDEO','VIGOR','VIRAL',
'VIRUS','VISIT','VITAL','VIVID','VOICE','VOTER','WAIST','WASTE','WATCH',
'WATER','WEARY','WEIGH','WEIRD','WHILE','WHITE','WHOLE','WHOSE','WIDER',
'WITCH','WOMAN','WOMEN','WORLD','WORRY','WORSE','WORST','WORTH','WOULD',
'WOUND','WRIST','WRITE','WROTE','YOUNG','YOURS','YOUTH','ZONAL',
]);
// ── Load word sets at startup ─────────────────────────────────────────────────
let allFiveLetterWords = null; // Set<string> — all valid 5-letter ENABLE words
let answerPool = null; // string[] — shuffleable answer list
function loadWordLists() {
if (allFiveLetterWords) return;
let raw = '';
try {
raw = fs.readFileSync(WORDLIST_PATH, 'utf8');
} catch (err) {
console.warn('[words] ENABLE word list not found, using fallback.');
raw = [...COMMON_WORDS].join('\n');
}
const allWords = raw.split('\n').map(w => w.trim().toUpperCase());
const enableFive = new Set(
allWords.filter(w => w.length === 5 && /^[A-Z]{5}$/.test(w)),
);
allFiveLetterWords = enableFive;
// Scrabble dictionary: every ENABLE word of legal play length (215 letters).
const scrabbleWords = new Set(
allWords.filter(w => w.length >= 2 && w.length <= 15 && /^[A-Z]+$/.test(w)),
);
initScrabbleDictionary(scrabbleWords);
console.log(`[words] loaded ${scrabbleWords.size} Scrabble words (215 letters)`);
// Ghost dictionary: every ENABLE word of length >= 4 (shorter words don't count).
const ghostWords = allWords.filter(w => w.length >= 4 && /^[A-Z]+$/.test(w));
initGhostDictionary(ghostWords);
console.log(`[words] loaded ${ghostWords.length} Ghost words (4+ 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));
answerPool = curated.length >= 200 ? curated : [...enableFive];
console.log(`[words] loaded ${enableFive.size} valid 5-letter words, ${answerPool.length} answer candidates`);
}
loadWordLists();
// ── Router ────────────────────────────────────────────────────────────────────
const router = Router();
// GET /api/words/wordle/start
// Returns a random answer word plus the full valid-word pool for the client.
router.get('/wordle/start', (_req, res) => {
const answer = answerPool[Math.floor(Math.random() * answerPool.length)];
res.json({ answer, validWords: [...allFiveLetterWords] });
});
// POST /api/words/wordle/validate { word: string }
// Quick single-word validation (used as a lightweight alternative to the full pool).
router.post('/wordle/validate', (req, res) => {
const word = (req.body?.word ?? '').trim().toUpperCase();
if (!/^[A-Z]{5}$/.test(word)) {
return res.json({ valid: false });
}
res.json({ valid: allFiveLetterWords.has(word) });
});
// ── Scrabble ──────────────────────────────────────────────────────────────────
// POST /api/words/scrabble/validate { words: string[] }
// Validates every word a human move forms. Returns the list of invalid ones.
router.post('/scrabble/validate', (req, res) => {
const words = Array.isArray(req.body?.words) ? req.body.words : [];
const invalid = words
.map(w => String(w).trim().toUpperCase())
.filter(w => !isValidWord(w));
res.json({ valid: invalid.length === 0, invalid });
});
// POST /api/words/scrabble/ai-move { board, rack, skill, firstMove, bagCount }
// Returns the AI's chosen move: a play, an exchange, or a pass.
router.post('/scrabble/ai-move', (req, res) => {
const { board, rack, skill, firstMove, bagCount } = req.body ?? {};
if (!Array.isArray(board) || !Array.isArray(rack)) {
return res.status(400).json({ error: 'board and rack are required' });
}
const move = chooseMove({
board,
rack,
skill: Number(skill) || 3,
firstMove: !!firstMove,
bagCount: Number(bagCount) || 0,
});
res.json(move);
});
// ── Ghost ───────────────────────────────────────────────────────────────────
// POST /api/words/ghost/judge { fragment: string }
// Grades the fragment a player just formed: a completed word, or off-dictionary,
// both lose the round for whoever made the move.
router.post('/ghost/judge', (req, res) => {
const fragment = String(req.body?.fragment ?? '');
res.json(ghostJudge(fragment));
});
// POST /api/words/ghost/ai-move { fragment: string, skill: number }
// Returns the AI's letter and how the resulting fragment grades.
router.post('/ghost/ai-move', (req, res) => {
const fragment = String(req.body?.fragment ?? '');
const skill = Number(req.body?.skill) || 3;
res.json(ghostChooseLetter(fragment, skill));
});
export default router;