feat(semantle): source target pool from google-10000-english dictionary

The ~250-word hand-curated TARGET_POOL was too small for long-term play.
Replaces it with a build-script-generated dictionary:

- scripts/build-semantle-words.js fetches first20hours/google-10000-english
  (no-swears variant), filters to 4–10 ASCII letters, drops the top-200
  most frequent function words, and writes src/modules/semantle/words-data.js
  as a static ES-module export.
- wordlist.js now just re-exports that data via TARGET_POOL + pickFromPool.
- package.json: new build:semantle-words script; chained into `npm run build`
  alongside build:wordle-data so `npm run deploy` regenerates automatically.

Pool size: ~250 → 7953 words. Same ConceptNet verify-and-fallback flow, so
low-quality picks still cost at most one extra concept lookup.
This commit is contained in:
2026-04-22 23:12:07 +07:00
parent b459cc9ee7
commit 866f6d663f
5 changed files with 8049 additions and 71 deletions
+2 -1
View File
@@ -9,8 +9,9 @@
},
"scripts": {
"dev": "wrangler dev",
"build": "npm run build:wordle-data",
"build": "npm run build:wordle-data && npm run build:semantle-words",
"build:wordle-data": "node scripts/build-wordle-data.js",
"build:semantle-words": "node scripts/build-semantle-words.js",
"scrape:loldle-data": "node scripts/scrape-loldle-data.js",
"deploy": "npm run build && wrangler deploy && npm run db:migrate && npm run register",
"db:migrate": "node scripts/migrate.js",
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* @file build-semantle-words — fetches a common-English word list (10k
* by Google Ngram frequency, curated by first20hours) and writes the
* 410 letter alphabetic subset to src/modules/semantle/words-data.js.
*
* Why this list:
* - Sorted by frequency → easy top-N trimming to drop function words
* (`the`, `of`, `and`) that make terrible guessing targets.
* - Already de-swear-ed. No further blocklist needed.
* - Tiny (~90 KB raw, ~30 KB gzipped) — comfortably inside the Worker
* size budget.
*
* Source: https://github.com/first20hours/google-10000-english
* Credits: Josh Kaufman (first20hours) — list derived from Peter Norvig's
* Google Ngram analysis.
*
* Usage:
* node scripts/build-semantle-words.js
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
const SOURCE_URL =
"https://raw.githubusercontent.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt";
// Skip the top-N most frequent words — too common, lousy puzzles.
const SKIP_TOP_N = 200;
const MIN_LEN = 4;
const MAX_LEN = 10;
const root = resolve(import.meta.dirname, "..");
const dst = resolve(root, "src/modules/semantle/words-data.js");
const res = await fetch(SOURCE_URL);
if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
const text = await res.text();
const lines = text.split(/\r?\n/).map((w) => w.trim().toLowerCase());
// Preserve original frequency order while filtering — downstream consumers
// can still sample uniformly, but the index itself stays a frequency rank.
const words = Array.from(
new Set(
lines
.slice(SKIP_TOP_N)
.filter((w) => w.length >= MIN_LEN && w.length <= MAX_LEN && /^[a-z]+$/.test(w)),
),
);
if (words.length === 0) throw new Error("no words parsed from source");
const body = words.map((w) => ` "${w}",`).join("\n");
const out = [
"// Auto-generated from https://github.com/first20hours/google-10000-english",
"// Credits: Josh Kaufman (first20hours) — common English words by Google Ngram frequency.",
`// Filter: ${MIN_LEN}${MAX_LEN} ASCII letters, skip top ${SKIP_TOP_N} most common.`,
"// Regenerate with: node scripts/build-semantle-words.js",
"export default [",
body,
"];",
"",
].join("\n");
writeFileSync(dst, out);
console.log(`wrote ${dst} (${words.length} words)`);
+10 -2
View File
@@ -29,10 +29,16 @@ ignored (no cost, no stat inflation).
carries at least one edge.
Because ConceptNet has no random-word endpoint, the target pool ships in
`wordlist.js` (~250 curated English words, 410 letters, all alphabetic).
`words-data.js` — ~8k common English words (410 ASCII letters) derived
from Google Ngram frequency via the public
[google-10000-english](https://github.com/first20hours/google-10000-english)
list, with the top-200 most frequent function words stripped.
Each new round picks locally, verifies via the concept endpoint, and falls
back to an unverified pick after a few misses.
Regenerate the list with `npm run build:semantle-words` (chained into the
main `npm run build` that `npm run deploy` invokes).
Every guess costs **two** ConceptNet calls (concept edges + relatedness)
issued in parallel. Typical latency ~300600ms round-trip from Cloudflare
Workers; `api-client.js` enforces a 5s timeout and surfaces a "Upstream
@@ -44,7 +50,8 @@ hiccup" message on failure.
plus lower-level `concept` / `relatedness`) with `UpstreamError` metadata.
Preserves the earlier word2sim response shape so the rest of the module
didn't need rewriting.
- `wordlist.js`curated local target pool and `pickFromPool()`.
- `words-data.js`auto-generated dictionary (regenerate via `npm run build:semantle-words`).
- `wordlist.js` — thin wrapper exposing `TARGET_POOL` and `pickFromPool()` over the dictionary.
- `state.js` — KV persistence for game + stats. Target stored lowercased.
- `lookup.js` — guess normalization and shape validation.
- `format.js` — warmth-percent and emoji-bucket formatters.
@@ -84,4 +91,5 @@ across all rounds.
## Credits
- Similarity + vocabulary: [ConceptNet 5](https://conceptnet.io) by Robyn Speer et al.
- Target dictionary: [google-10000-english](https://github.com/first20hours/google-10000-english) by Josh Kaufman, derived from Peter Norvig's Google Ngram analysis.
- Game concept: [Semantle](https://semantle.com/) by David Turner.
+11 -68
View File
@@ -1,75 +1,18 @@
/**
* @file Curated target-word pool for the semantle module.
* @file Target-word pool for the semantle module.
*
* ConceptNet has no random-word endpoint, so we ship a hand-picked list of
* common, game-friendly English nouns/verbs/adjectives (410 ASCII letters).
* The list is small on purpose — every entry is a reasonable Semantle target,
* which matters more than raw size. Expand freely as the game matures.
*
* Entries are lowercase and alphabetic only (the similarity endpoint accepts
* these directly as `/c/en/<word>` concept IDs).
* The raw dictionary lives in `words-data.js` (auto-generated by
* `scripts/build-semantle-words.js` from the public google-10000-english
* list, filtered to 410 ASCII letters with the top-200 most frequent
* function words stripped). This module just exposes the list and a
* uniform random pick so api-client.js can stay focused on HTTP.
*/
// biome-ignore format: keep the list compact and grep-friendly
export const TARGET_POOL = [
// nature / geography
"ocean", "mountain", "forest", "desert", "river", "valley", "garden", "island",
"beach", "cave", "meadow", "glacier", "volcano", "canyon", "jungle", "lake",
"hill", "plateau", "cliff", "harbor", "coast", "swamp", "prairie", "tundra",
"delta", "creek", "stream", "pebble", "boulder", "horizon", "iceberg", "dune",
// weather / time
"winter", "summer", "autumn", "spring", "morning", "evening", "midnight",
"sunrise", "sunset", "thunder", "rainbow", "blizzard", "breeze", "drought",
"storm", "shadow", "twilight", "decade",
// people / relations
"friend", "family", "mother", "father", "brother", "sister", "child",
"stranger", "neighbor", "partner", "sibling", "elder", "infant",
// arts
"music", "dance", "poem", "story", "painting", "theater", "cinema", "novel",
"symphony", "sculpture", "sketch", "ballet", "opera", "concert",
// objects
"computer", "phone", "camera", "robot", "engine", "wheel", "pencil", "hammer",
"mirror", "bicycle", "umbrella", "lantern", "compass", "anchor", "blanket",
"candle", "cushion", "kettle", "ladder", "needle", "paper", "pillow",
"scissors", "telescope", "throne", "vase", "window", "zipper", "bottle",
"basket", "bridge", "tower",
// animals
"eagle", "tiger", "dolphin", "rabbit", "snake", "salmon", "wolf", "horse",
"butterfly", "elephant", "panda", "falcon", "sparrow", "penguin", "octopus",
"beetle", "crow", "dragon", "hawk", "jaguar", "kangaroo", "lion", "monkey",
"otter", "parrot", "raccoon", "squirrel", "turtle", "whale", "bear", "fox",
"shark",
// food
"apple", "bread", "cheese", "coffee", "sugar", "pepper", "potato", "orange",
"honey", "chocolate", "cinnamon", "almond", "berry", "butter", "grape",
"lemon", "olive", "tomato", "walnut", "yogurt", "ginger",
// emotions / abstract
"love", "anger", "fear", "courage", "sorrow", "wonder", "dream", "memory",
"silence", "laughter", "hope", "delight", "regret", "trust", "justice",
"freedom", "honor", "peace", "victory", "promise", "secret", "truth",
"wisdom", "mystery", "destiny", "patience", "loyalty",
// professions
"teacher", "doctor", "artist", "farmer", "pilot", "soldier", "writer",
"sailor", "hunter", "engineer", "chef", "dentist", "nurse", "judge",
"scientist",
// body
"shoulder", "finger", "elbow", "throat", "pulse", "tongue", "ankle", "spine",
"muscle", "nerve", "eyelid", "beard", "tooth", "thumb", "knee",
// verbs / actions
"gather", "wonder", "linger", "stumble", "whisper", "shimmer", "wander",
"rescue", "gallop", "imagine", "pursue", "retreat", "thrive", "squeeze",
"shatter", "tremble", "travel", "borrow", "descend", "inherit", "vanish",
"forget", "invite", "resist", "settle",
// adjectives
"ancient", "gentle", "fragile", "massive", "glowing", "rugged", "silent",
"frozen", "steady", "brittle", "clever", "gloomy", "cheerful", "noisy",
"silver", "golden", "radiant", "distant", "graceful", "humble", "vivid",
"quiet", "sacred", "sudden", "tender", "wealthy", "generous", "majestic",
];
import WORDS from "./words-data.js";
/**
* @returns {string} — a random lowercase word from the pool.
*/
export const TARGET_POOL = WORDS;
/** @returns {string} — a random lowercase word from the pool. */
export function pickFromPool() {
return TARGET_POOL[Math.floor(Math.random() * TARGET_POOL.length)];
return WORDS[Math.floor(Math.random() * WORDS.length)];
}
File diff suppressed because it is too large Load Diff