more words2
This commit is contained in:
14
package-lock.json
generated
14
package-lock.json
generated
@@ -9,12 +9,11 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@esbuild/linux-x64": "*",
|
|
||||||
"@rollup/rollup-linux-x64-gnu": "*",
|
|
||||||
"sirv-cli": "^3.0.1",
|
"sirv-cli": "^3.0.1",
|
||||||
"wordlist-english": "^1.2.1"
|
"wordlist-english": "^1.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"an-array-of-english-words": "^2.0.0",
|
||||||
"vite": "^6.4.5"
|
"vite": "^6.4.5"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
@@ -884,6 +883,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/an-array-of-english-words": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/an-array-of-english-words/-/an-array-of-english-words-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-FXnNvZSOI27kkKXeLSquhaTGP7z198UOQ4txaYO9fCfrjCh+D5SV7G7XqzEH0229+pAi4cjBEZ4WIQYgjKtO7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"an-array-of-english-words": "cli.js",
|
||||||
|
"words": "cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/console-clear": {
|
"node_modules/console-clear": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz",
|
||||||
|
|||||||
@@ -7,13 +7,15 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"start": "sirv dist --single --host 0.0.0.0 --port ${PORT:-3000}"
|
"start": "sirv dist --single --host 0.0.0.0 --port ${PORT:-3000}",
|
||||||
|
"data": "node scripts/build-client-lexicon.mjs && node scripts/build-all-data.mjs && node scripts/find-missing-combo-words.mjs --write && node scripts/build-client-lexicon.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"an-array-of-english-words": "^2.0.0",
|
||||||
"vite": "^6.4.5"
|
"vite": "^6.4.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { buildWordSet } from './lexicon-sources.mjs';
|
import { buildWordSet, loadComboWords } from './lexicon-sources.mjs';
|
||||||
|
|
||||||
// Build a clean, high-frequency recognized vocabulary for client-side unlimited/custom puzzles
|
// Build a clean, high-frequency recognized vocabulary for client-side unlimited/custom puzzles
|
||||||
// (includes regional spellings and the proper-word allowlist)
|
// (includes regional spellings and the proper-word allowlist)
|
||||||
const vocabulary = buildWordSet();
|
const vocabulary = buildWordSet();
|
||||||
|
|
||||||
|
// Accept-only words (scripts/combo-words.txt): valid in play, never shown in solution lists
|
||||||
|
const comboWords = [...new Set(loadComboWords())].filter(w => !vocabulary.has(w)).sort();
|
||||||
|
|
||||||
console.log('Total vocabulary words:', vocabulary.size);
|
console.log('Total vocabulary words:', vocabulary.size);
|
||||||
|
console.log('Accept-only combo words:', comboWords.length);
|
||||||
|
|
||||||
// Pack into space-separated string or array
|
// Pack into space-separated string or array
|
||||||
const sortedWords = [...vocabulary].sort();
|
const sortedWords = [...vocabulary].sort();
|
||||||
@@ -15,6 +19,11 @@ const RAW_LEXICON = ${JSON.stringify(sortedWords)};
|
|||||||
|
|
||||||
export const DICTIONARY_SET = new Set(RAW_LEXICON);
|
export const DICTIONARY_SET = new Set(RAW_LEXICON);
|
||||||
|
|
||||||
|
// Accept-only words buildable from pool tiles (generated by
|
||||||
|
// scripts/find-missing-combo-words.mjs). Space-packed to keep the bundle small.
|
||||||
|
const RAW_COMBO_WORDS = ${JSON.stringify(comboWords.join(' '))};
|
||||||
|
export const COMBO_WORD_SET = new Set(RAW_COMBO_WORDS ? RAW_COMBO_WORDS.split(' ') : []);
|
||||||
|
|
||||||
// Prefix set for lightning fast tree-pruning search (1 to 6 chars)
|
// Prefix set for lightning fast tree-pruning search (1 to 6 chars)
|
||||||
export const PREFIX_SET = new Set();
|
export const PREFIX_SET = new Set();
|
||||||
for (const word of RAW_LEXICON) {
|
for (const word of RAW_LEXICON) {
|
||||||
@@ -26,6 +35,12 @@ for (const word of RAW_LEXICON) {
|
|||||||
export function isWordValid(word) {
|
export function isWordValid(word) {
|
||||||
return DICTIONARY_SET.has(word.toLowerCase());
|
return DICTIONARY_SET.has(word.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any word a player may submit: main dictionary + accept-only combo words
|
||||||
|
export function isAcceptedWord(word) {
|
||||||
|
const w = word.toLowerCase();
|
||||||
|
return DICTIONARY_SET.has(w) || COMBO_WORD_SET.has(w);
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
fs.writeFileSync('src/data/lexicon.js', lexiconContent, 'utf8');
|
fs.writeFileSync('src/data/lexicon.js', lexiconContent, 'utf8');
|
||||||
|
|||||||
66464
scripts/combo-words.txt
Normal file
66464
scripts/combo-words.txt
Normal file
File diff suppressed because it is too large
Load Diff
67
scripts/find-missing-combo-words.mjs
Normal file
67
scripts/find-missing-combo-words.mjs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Finds words that can be built from 1-4 distinct tiles in the puzzle pool
|
||||||
|
// but are missing from our dictionary, using a large reference word list
|
||||||
|
// (an-array-of-english-words, ~275k words) as the source of truth.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/find-missing-combo-words.mjs # report only
|
||||||
|
// node scripts/find-missing-combo-words.mjs --write # save scripts/combo-words.txt
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { buildWordSet } from './lexicon-sources.mjs';
|
||||||
|
import { TARGET_WORDS_POOL, CURATED_PUZZLES } from '../src/data/puzzles.js';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const REFERENCE = require('an-array-of-english-words');
|
||||||
|
const OUT_FILE = 'scripts/combo-words.txt';
|
||||||
|
|
||||||
|
// Every tile that can appear on a board (random + daily puzzles)
|
||||||
|
const chunks = new Set();
|
||||||
|
TARGET_WORDS_POOL.forEach(t => t.tiles.forEach(c => chunks.add(c.toLowerCase())));
|
||||||
|
CURATED_PUZZLES.forEach(p => p.quartets.flat().forEach(c => chunks.add(c.toLowerCase())));
|
||||||
|
const maxChunkLen = Math.max(...[...chunks].map(c => c.length));
|
||||||
|
|
||||||
|
// Base dictionary WITHOUT the previously generated combo list, so re-runs are stable
|
||||||
|
const dictionary = buildWordSet({ includeComboWords: false });
|
||||||
|
|
||||||
|
// Can `word` be split into 1-4 distinct pool tiles? Returns the split or null.
|
||||||
|
function segment(word, start = 0, used = []) {
|
||||||
|
if (start === word.length) return used.length ? [...used] : null;
|
||||||
|
if (used.length === 4) return null;
|
||||||
|
for (let len = 1; len <= maxChunkLen && start + len <= word.length; len++) {
|
||||||
|
const piece = word.slice(start, start + len);
|
||||||
|
if (!chunks.has(piece) || used.includes(piece)) continue;
|
||||||
|
used.push(piece);
|
||||||
|
const result = segment(word, start + len, used);
|
||||||
|
used.pop();
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = [];
|
||||||
|
for (const raw of REFERENCE) {
|
||||||
|
const w = raw.toLowerCase();
|
||||||
|
if (w.length < 3 || w.length > 16 || !/^[a-z]+$/.test(w)) continue; // 2-letter words use the fixed allowlist
|
||||||
|
if (dictionary.has(w)) continue;
|
||||||
|
const split = segment(w);
|
||||||
|
if (split) found.push({ word: w, split });
|
||||||
|
}
|
||||||
|
found.sort((a, b) => a.word.localeCompare(b.word));
|
||||||
|
|
||||||
|
console.log(`Pool tiles: ${chunks.size}`);
|
||||||
|
console.log(`Missing words buildable from pool tiles: ${found.length}`);
|
||||||
|
const byTiles = { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||||
|
found.forEach(f => byTiles[f.split.length]++);
|
||||||
|
console.log('By tile count:', byTiles);
|
||||||
|
console.log('Sample:', found.slice(0, 40).map(f => `${f.word}(${f.split.join('+')})`).join(' '));
|
||||||
|
|
||||||
|
if (process.argv.includes('--write')) {
|
||||||
|
const header = [
|
||||||
|
'# Words buildable from pool tiles that are missing from wordlist-english.',
|
||||||
|
'# Generated by scripts/find-missing-combo-words.mjs (source: an-array-of-english-words).',
|
||||||
|
'# These are accepted in play but never shown in the 30-word solution lists.',
|
||||||
|
'# Delete a line to stop accepting that word.'
|
||||||
|
];
|
||||||
|
fs.writeFileSync(OUT_FILE, [...header, ...found.map(f => f.word)].join('\n') + '\n', 'utf8');
|
||||||
|
console.log(`Saved ${found.length} words to ${OUT_FILE}`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
import wordlistEnglish from 'wordlist-english';
|
import wordlistEnglish from 'wordlist-english';
|
||||||
|
|
||||||
|
const COMBO_WORDS_FILE = new URL('./combo-words.txt', import.meta.url);
|
||||||
|
|
||||||
|
// Extra words (from scripts/find-missing-combo-words.mjs) that are buildable
|
||||||
|
// from pool tiles but missing from wordlist-english. Accepted in play only.
|
||||||
|
export function loadComboWords() {
|
||||||
|
if (!fs.existsSync(COMBO_WORDS_FILE)) return [];
|
||||||
|
return fs.readFileSync(COMBO_WORDS_FILE, 'utf8')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(l => l.trim().toLowerCase())
|
||||||
|
.filter(l => l && !l.startsWith('#') && /^[a-z]+$/.test(l));
|
||||||
|
}
|
||||||
|
|
||||||
// Shared dictionary sources for the client lexicon and the build-time solver.
|
// Shared dictionary sources for the client lexicon and the build-time solver.
|
||||||
|
|
||||||
// Valid 2-letter English words accepted in standard games
|
// Valid 2-letter English words accepted in standard games
|
||||||
@@ -40,7 +53,7 @@ function isAcceptable(w) {
|
|||||||
return w.length >= 3 && w.length <= 16 && /^[a-z]+$/.test(w);
|
return w.length >= 3 && w.length <= 16 && /^[a-z]+$/.test(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWordSet() {
|
export function buildWordSet({ includeComboWords = false } = {}) {
|
||||||
const set = new Set();
|
const set = new Set();
|
||||||
for (const key of SOURCE_LISTS) {
|
for (const key of SOURCE_LISTS) {
|
||||||
for (const raw of wordlistEnglish[key] || []) {
|
for (const raw of wordlistEnglish[key] || []) {
|
||||||
@@ -51,6 +64,11 @@ export function buildWordSet() {
|
|||||||
for (const w of PROPER_WORD_ALLOWLIST) {
|
for (const w of PROPER_WORD_ALLOWLIST) {
|
||||||
if (isAcceptable(w)) set.add(w);
|
if (isAcceptable(w)) set.add(w);
|
||||||
}
|
}
|
||||||
|
if (includeComboWords) {
|
||||||
|
for (const w of loadComboWords()) {
|
||||||
|
if (isAcceptable(w)) set.add(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
return set;
|
return set;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||||
import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '../engine/constants.js';
|
import { getRankInfo, QUARTILE_COMPLETION_BONUS, POINTS_BY_TILE_COUNT } from '../engine/constants.js';
|
||||||
import { DICTIONARY_SET } from '../data/lexicon.js';
|
import { isAcceptedWord } from '../data/lexicon.js';
|
||||||
|
|
||||||
class GameStateManager {
|
class GameStateManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -178,7 +178,7 @@ class GameStateManager {
|
|||||||
entry.isBonus = false;
|
entry.isBonus = false;
|
||||||
filteredEntries.push([key, entry]);
|
filteredEntries.push([key, entry]);
|
||||||
recalculatedScore += matchedWord.points;
|
recalculatedScore += matchedWord.points;
|
||||||
} else if (entry && DICTIONARY_SET.has(key)) {
|
} else if (entry && isAcceptedWord(key)) {
|
||||||
// Valid word beyond the displayed 30-word list
|
// Valid word beyond the displayed 30-word list
|
||||||
entry.isBonus = true;
|
entry.isBonus = true;
|
||||||
entry.points = POINTS_BY_TILE_COUNT[entry.tileCount] || 1;
|
entry.points = POINTS_BY_TILE_COUNT[entry.tileCount] || 1;
|
||||||
@@ -448,7 +448,7 @@ class GameStateManager {
|
|||||||
|
|
||||||
// Any real word built from the tiles counts, even if it was cut from the
|
// Any real word built from the tiles counts, even if it was cut from the
|
||||||
// displayed 30-word list (accepted as a bonus word)
|
// displayed 30-word list (accepted as a bonus word)
|
||||||
const isBonus = !listed && DICTIONARY_SET.has(constructedWord);
|
const isBonus = !listed && isAcceptedWord(constructedWord);
|
||||||
const match = listed || (isBonus ? { points: POINTS_BY_TILE_COUNT[tileCount] || 1 } : null);
|
const match = listed || (isBonus ? { points: POINTS_BY_TILE_COUNT[tileCount] || 1 } : null);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
|||||||
Reference in New Issue
Block a user