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, suggestWords as ghostSuggestWords } from './ghostEngine.js'; import { initWordLadderDictionary, generatePuzzle as ladderGeneratePuzzle, generateVersusPuzzles as ladderGenerateVersus, chooseAIMove as ladderChooseAIMove, hintMove as ladderHintMove, validWordsOfLength as ladderValidWords, } from './wordLadderEngine.js'; import { generatePuzzle as wordSearchGenerate, listThemes as wordSearchThemes, } from './wordSearchEngine.js'; import { generatePuzzle as sudokuGenerate } from './sudokuEngine.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 — 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 (2–15 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 (2–15 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)`); // Word Ladder dictionaries: 3- and 4-letter words for the two game variants. const ladderThree = allWords.filter(w => /^[A-Z]{3}$/.test(w)); const ladderFour = allWords.filter(w => /^[A-Z]{4}$/.test(w)); initWordLadderDictionary(ladderThree, ladderFour); console.log(`[words] loaded Word Ladder dictionaries (${ladderThree.length} 3-letter, ${ladderFour.length} 4-letter)`); // 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)); }); // POST /api/words/ghost/suggest { prefix: string, max?: number } // Example safe words the loser could have headed toward from the faced prefix. router.post('/ghost/suggest', (req, res) => { const prefix = String(req.body?.prefix ?? ''); const max = Number(req.body?.max) || 3; res.json({ words: ghostSuggestWords(prefix, max) }); }); // ── Word Ladder ──────────────────────────────────────────────────────────────── // GET /api/words/wordladder/start?length=3|4&versus=true|false // Solo: { player, opponent: null, validWords }. // Versus: { player, opponent, validWords } — two puzzles of equal par. router.get('/wordladder/start', (req, res) => { const length = Number(req.query.length) === 3 ? 3 : 4; const versus = String(req.query.versus) === 'true'; const validWords = ladderValidWords(length); if (versus) { const { player, opponent } = ladderGenerateVersus(length); return res.json({ player, opponent, validWords }); } res.json({ player: ladderGeneratePuzzle(length), opponent: null, validWords }); }); // POST /api/words/wordladder/ai-move { current, target, skill } // Advances the AI opponent one rung toward its target. router.post('/wordladder/ai-move', (req, res) => { const current = String(req.body?.current ?? ''); const target = String(req.body?.target ?? ''); const skill = Number(req.body?.skill) || 3; res.json(ladderChooseAIMove(current, target, skill)); }); // POST /api/words/wordladder/hint { current, target } // One valid next step on a shortest path toward the target. router.post('/wordladder/hint', (req, res) => { const current = String(req.body?.current ?? ''); const target = String(req.body?.target ?? ''); res.json({ word: ladderHintMove(current, target) }); }); // ── Word Search ────────────────────────────────────────────────────────────── // ── Hangman ────────────────────────────────────────────────────────────────── const HANGMAN_WORDS = { easy: [ { word: 'CAT', hint: 'A common household pet', category: 'Animals' }, { word: 'DOG', hint: 'Man\'s best friend', category: 'Animals' }, { word: 'SUN', hint: 'It lights up the day', category: 'Nature' }, { word: 'FISH', hint: 'Lives in water', category: 'Animals' }, { word: 'BIRD', hint: 'Has wings and feathers', category: 'Animals' }, { word: 'TREE', hint: 'Tall plant with a trunk', category: 'Nature' }, { word: 'CAKE', hint: 'Sweet birthday treat', category: 'Food' }, { word: 'STAR', hint: 'Twinkles in the night sky', category: 'Nature' }, { word: 'FROG', hint: 'Hops and croaks', category: 'Animals' }, { word: 'RAIN', hint: 'Falls from clouds', category: 'Nature' }, { word: 'DUCK', hint: 'Says quack', category: 'Animals' }, { word: 'SNOW', hint: 'White and cold', category: 'Nature' }, { word: 'BEAR', hint: 'Loves honey', category: 'Animals' }, { word: 'MILK', hint: 'Comes from a cow', category: 'Food' }, { word: 'MOON', hint: 'Orbits the Earth', category: 'Nature' }, { word: 'WOLF', hint: 'Howls at the moon', category: 'Animals' }, { word: 'CORN', hint: 'Yellow vegetable on a cob', category: 'Food' }, { word: 'ROSE', hint: 'A thorny flower', category: 'Nature' }, { word: 'LION', hint: 'King of the jungle', category: 'Animals' }, { word: 'LEAF', hint: 'Falls from trees in autumn', category: 'Nature' }, ], medium: [ { word: 'CASTLE', hint: 'A medieval fortress', category: 'Places' }, { word: 'JUNGLE', hint: 'Dense tropical forest', category: 'Nature' }, { word: 'PLANET', hint: 'Orbits a star', category: 'Space' }, { word: 'BRIDGE', hint: 'Spans a river or gap', category: 'Structures'}, { word: 'CAMERA', hint: 'Used to take photos', category: 'Objects' }, { word: 'DRAGON', hint: 'Mythical fire-breathing creature', category: 'Fantasy' }, { word: 'FOREST', hint: 'Dense woodland', category: 'Nature' }, { word: 'GUITAR', hint: 'Six-stringed instrument', category: 'Music' }, { word: 'ISLAND', hint: 'Land surrounded by water', category: 'Places' }, { word: 'KNIGHT', hint: 'Armored medieval warrior', category: 'History' }, { word: 'LEMON', hint: 'Sour yellow citrus fruit', category: 'Food' }, { word: 'MARBLE', hint: 'Smooth polished stone', category: 'Objects' }, { word: 'ORANGE', hint: 'A citrus fruit or color', category: 'Food' }, { word: 'PENCIL', hint: 'Used for writing and drawing', category: 'Objects' }, { word: 'PUZZLE', hint: 'A brain-teasing challenge', category: 'Games' }, { word: 'RABBIT', hint: 'Hops and has long ears', category: 'Animals' }, { word: 'ROCKET', hint: 'Launches into space', category: 'Space' }, { word: 'SCHOOL', hint: 'Where students learn', category: 'Places' }, { word: 'SPIDER', hint: 'Eight-legged arachnid', category: 'Animals' }, { word: 'TURTLE', hint: 'Has a shell on its back', category: 'Animals' }, { word: 'VIOLIN', hint: 'Bowed string instrument', category: 'Music' }, { word: 'WINDOW', hint: 'Lets in light and air', category: 'Structures'}, { word: 'WINTER', hint: 'The coldest season', category: 'Nature' }, { word: 'WIZARD', hint: 'Casts magical spells', category: 'Fantasy' }, ], hard: [ { word: 'ARCHITECT', hint: 'Designs buildings', category: 'Professions' }, { word: 'BACKPACK', hint: 'Worn on your back for carrying', category: 'Objects' }, { word: 'BREAKFAST', hint: 'The first meal of the day', category: 'Food' }, { word: 'BUTTERFLY', hint: 'Colorful winged insect', category: 'Animals' }, { word: 'CALENDAR', hint: 'Tracks days and months', category: 'Objects' }, { word: 'CHOCOLATE', hint: 'Sweet treat from cacao beans', category: 'Food' }, { word: 'CLOCKWORK', hint: 'Gears and springs mechanism', category: 'Objects' }, { word: 'CROCODILE', hint: 'Large reptile in rivers', category: 'Animals' }, { word: 'DAUGHTER', hint: 'Female offspring', category: 'People' }, { word: 'DISCOVERY', hint: 'Finding something new', category: 'Concepts' }, { word: 'EARTHQUAKE', hint: 'Shaking of the ground', category: 'Nature' }, { word: 'FINGERTIP', hint: 'End of a finger', category: 'Body' }, { word: 'FIREWORKS', hint: 'Colorful sky explosions', category: 'Events' }, { word: 'FLAMINGO', hint: 'Pink bird that stands on one leg', category: 'Animals' }, { word: 'GEOGRAPHY', hint: 'Study of Earth and its features', category: 'Science' }, { word: 'HURRICANE', hint: 'Powerful tropical storm', category: 'Nature' }, { word: 'JELLYFISH', hint: 'Translucent ocean creature', category: 'Animals' }, { word: 'KANGAROO', hint: 'Marsupial that hops', category: 'Animals' }, { word: 'LIGHTHOUSE', hint: 'Tower with a guiding beacon', category: 'Structures' }, { word: 'MUSHROOM', hint: 'A fungus with a cap', category: 'Nature' }, { word: 'PORCUPINE', hint: 'Covered in sharp quills', category: 'Animals' }, { word: 'QUICKSAND', hint: 'Wet sand that sucks you in', category: 'Nature' }, { word: 'SUBMARINE', hint: 'Vessel that travels underwater', category: 'Vehicles' }, { word: 'TELESCOPE', hint: 'Used to observe distant objects', category: 'Science' }, { word: 'THUNDERSTORM',hint: 'Rain with lightning and thunder', category: 'Nature' }, { word: 'WATERFALL', hint: 'Water cascading over a cliff', category: 'Nature' }, ], }; // GET /api/words/hangman/start?difficulty=easy|medium|hard router.get('/hangman/start', (req, res) => { const difficulty = ['easy', 'medium', 'hard'].includes(req.query.difficulty) ? req.query.difficulty : 'medium'; const pool = HANGMAN_WORDS[difficulty]; const entry = pool[Math.floor(Math.random() * pool.length)]; res.json({ word: entry.word, hint: entry.hint, category: entry.category, maxWrong: 7 }); }); // ── Word Search ────────────────────────────────────────────────────────────── // GET /api/words/wordsearch/start?difficulty=easy|medium|hard&theme=random|space|... // Returns a generated puzzle: { difficulty, theme, themeLabel, size, words, placements, grid }. router.get('/wordsearch/start', (req, res) => { const difficulty = String(req.query.difficulty ?? 'medium').toLowerCase(); const theme = req.query.theme ? String(req.query.theme).toLowerCase() : 'random'; res.json(wordSearchGenerate({ difficulty, theme })); }); // GET /api/words/wordsearch/themes — list of { id, label } for the start panel. router.get('/wordsearch/themes', (_req, res) => { res.json({ themes: wordSearchThemes() }); }); // GET /api/words/sudoku/start?difficulty=very-easy|easy|regular|hard|brutal router.get('/sudoku/start', (req, res) => { const VALID = ['very-easy', 'easy', 'regular', 'hard', 'brutal']; const difficulty = VALID.includes(req.query.difficulty) ? req.query.difficulty : 'regular'; res.json(sudokuGenerate(difficulty)); }); export default router;