Skip to content

Commit f219fbd

Browse files
committed
wordsearch prototype
1 parent b999a54 commit f219fbd

23 files changed

Lines changed: 11837 additions & 830 deletions

experiments/wordsearch/.llmignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ tests/
22
*.md
33
node_modules/
44
.idea/
5+
package-lock.json
6+
wordlist.txt

experiments/wordsearch/index.html

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ <h1>Markov Wordsearch</h1>
4848
<div>Time: <span id="play-timer">00:00</span></div>
4949
<p>Click the first letter, then the last letter of a word.</p>
5050
<ul id="play-words"></ul>
51+
<div id="play-bonus-wrap" hidden>
52+
<strong>Bonus words found:</strong>
53+
<ul id="play-bonus"></ul>
54+
</div>
5155
<div class="actions">
56+
<button id="btn-play-pause" class="secondary">Pause</button>
5257
<button id="btn-play-new">New game</button>
5358
</div>
5459
<div id="play-status"></div>
@@ -81,7 +86,7 @@ <h1>Markov Wordsearch</h1>
8186
<!-- <option value="triangular">triangular (8-dir)</option>-->
8287
</select>
8388
<label>
84-
<input id="cfg-no-backwards" type="checkbox" />
89+
<input id="cfg-no-backwards" type="checkbox" checked="true" />
8590
Disable backwards-oriented directions
8691
</label>
8792

@@ -119,6 +124,15 @@ <h1>Markov Wordsearch</h1>
119124
</label>
120125
<input id="cfg-wordcount" type="number" min="0" max="200" value="0" />
121126
<label><input id="cfg-debug" type="checkbox" /> Highlight placed words</label>
127+
<label for="cfg-max-adjacency">Max word adjacency</label>
128+
<input type="number" id="cfg-max-adjacency" min="0" max="5" step="1" value="1" />
129+
</fieldset>
130+
<fieldset>
131+
<legend>Randomness</legend>
132+
<label for="cfg-seed">
133+
Seed (blank = random; set a value for a shareable, repeatable game)
134+
</label>
135+
<input id="cfg-seed" type="text" placeholder="e.g. dragon-42" />
122136
</fieldset>
123137

124138
<fieldset>
@@ -144,5 +158,7 @@ <h1>Markov Wordsearch</h1>
144158
</main>
145159

146160
<script type="module" src="./src/index.js"></script>
161+
162+
<a class="home-link" href="../../index.html" title="Back to home">← Home</a>
147163
</body>
148164
</html>

experiments/wordsearch/src/fill/filler.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ import { buildForbiddenIndex } from '../grid/wordlist.js';
99
* @param {'weighted'|'argmax'} mode
1010
* @param {() => number} rng
1111
* @param {string[]} fallbackAlphabet
12+
* @param {Set<string>} [avoid]
1213
*/
13-
export function select(dist, mode, rng, fallbackAlphabet) {
14+
export function select(dist, mode, rng, fallbackAlphabet, avoid = null) {
1415
if (!dist || dist.size === 0) {
15-
const a = fallbackAlphabet;
16+
// No usable distribution: pick a random alphabet char that isn't forbidden.
17+
let a = fallbackAlphabet;
18+
if (avoid && avoid.size) {
19+
const safe = a.filter((c) => !avoid.has(c));
20+
if (safe.length) a = safe;
21+
}
1622
return a.length ? a[Math.floor(rng() * a.length)] : 'x';
1723
}
1824
if (mode === 'argmax') {
@@ -59,6 +65,10 @@ function forbiddenChars(grid, x, y, dirs, forbidden, lattice) {
5965
const reach = forbidden.maxLen - 1;
6066
for (const d of dirs) {
6167
const { before, after } = readLineAround(grid, x, y, d, reach, reach, lattice);
68+
if ((before && before.length) || (after && after.length)) {
69+
// Trace what context the avoidance check is actually seeing.
70+
console.debug(`[fill] (${x},${y}) dir=${d.name} before="${before}" after="${after}"`);
71+
}
6272
// The candidate char sits between `before` and `after`. Any contiguous
6373
// substring of `${before}${candidate}${after}` that includes the
6474
// candidate and matches a forbidden word is disallowed.
@@ -87,6 +97,9 @@ function forbiddenChars(grid, x, y, dirs, forbidden, lattice) {
8797
}
8898
}
8999
}
100+
if (avoid.size) {
101+
console.debug(`[fill] (${x},${y}) avoiding chars: ${[...avoid].join(',')}`);
102+
}
90103
return avoid;
91104
}
92105
/**
@@ -95,7 +108,6 @@ function forbiddenChars(grid, x, y, dirs, forbidden, lattice) {
95108
* original distribution is returned unchanged (we'd rather risk a word
96109
* than fail to fill a cell).
97110
* @param {Map<string,number>} dist
98-
* @param {Set<string>} avoid
99111
* @returns {Map<string,number>}
100112
*/
101113
function pruneDistribution(dist, avoid) {
@@ -107,7 +119,14 @@ function pruneDistribution(dist, avoid) {
107119
out.set(c, p);
108120
total += p;
109121
}
110-
if (out.size === 0 || total <= 0) return dist;
122+
if (out.size === 0 || total <= 0) {
123+
// Everything in the model distribution was forbidden. Before giving up,
124+
// try ANY alphabet character that isn't in the avoid set — the model
125+
// distribution is only a subset of the alphabet, so there are usually
126+
// plenty of safe (if unlikely) letters available.
127+
console.warn('[fill] all model candidates forbidden; searching alphabet for a safe char');
128+
return new Map(); // signal caller to fall back to alphabet-level selection
129+
}
111130
for (const [c, p] of out) out.set(c, p / total);
112131
return out;
113132
}
@@ -158,7 +177,7 @@ export function* fillGridSteps(grid, model, config = {}) {
158177
// Avoid accidentally constructing any target word in the filler.
159178
const avoid = forbiddenChars(grid, x, y, dirs, forbidden, lattice);
160179
combined = pruneDistribution(combined, avoid);
161-
const ch = select(combined, sampling, rng, alphabet);
180+
const ch = select(combined, sampling, rng, alphabet, avoid);
162181
grid.set(x, y, ch);
163182
yield { x, y, ch, contexts };
164183
}
@@ -209,7 +228,7 @@ export function fillGrid(grid, model, config = {}) {
209228
// Avoid accidentally constructing any target word in the filler.
210229
const avoid = forbiddenChars(grid, x, y, dirs, forbidden, lattice);
211230
combined = pruneDistribution(combined, avoid);
212-
const ch = select(combined, sampling, rng, alphabet);
231+
const ch = select(combined, sampling, rng, alphabet, avoid);
213232
grid.set(x, y, ch);
214233
}
215234
return grid;
Lines changed: 155 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,152 @@
11
import { latticeDirections, step } from './directions.js';
22
import { normaliseText } from '../markov/textPipeline.js';
3+
/**
4+
* Count how many distinct already-placed words a candidate placement would
5+
* touch — either by overlapping a shared cell or by sitting orthogonally/
6+
* diagonally adjacent to one. Uses the per-cell ownership map.
7+
*
8+
* @param {Array<{x:number,y:number,ch:string}>} coords
9+
* @param {Map<string, Set<number>>} ownership cellKey -> set of word indices
10+
* @param {import('./Grid.js').Grid} grid
11+
* @returns {number}
12+
*/
13+
function countAdjacentWords(coords, ownership, grid) {
14+
const touched = new Set();
15+
const selfKeys = new Set(coords.map((c) => `${c.x},${c.y}`));
16+
for (const c of coords) {
17+
// Check the cell itself (overlap) and its 8 neighbours (adjacency).
18+
for (let dy = -1; dy <= 1; dy++) {
19+
for (let dx = -1; dx <= 1; dx++) {
20+
const nx = c.x + dx;
21+
const ny = c.y + dy;
22+
if (!grid.inBounds(nx, ny)) continue;
23+
const key = `${nx},${ny}`;
24+
// Skip cells that are part of this same candidate placement.
25+
if (selfKeys.has(key)) continue;
26+
const owners = ownership.get(key);
27+
if (owners) for (const idx of owners) touched.add(idx);
28+
}
29+
}
30+
}
31+
return touched.size;
32+
}
33+
/**
34+
* Fisher–Yates shuffle (in place) using the supplied rng. Returns the array.
35+
* @template T
36+
* @param {T[]} arr
37+
* @param {() => number} rng
38+
* @returns {T[]}
39+
*/
40+
function shuffle(arr, rng) {
41+
for (let i = arr.length - 1; i > 0; i--) {
42+
const j = Math.floor(rng() * (i + 1));
43+
const tmp = arr[i];
44+
arr[i] = arr[j];
45+
arr[j] = tmp;
46+
}
47+
return arr;
48+
}
49+
50+
/**
51+
* Walk a candidate placement of `word` starting at (x, y) heading in
52+
* direction `dirName`. Returns a descriptor of the run, or null if the
53+
* word can't fit / conflicts with an incompatible existing letter.
54+
*
55+
* The descriptor includes an `overlap` count: how many cells coincide
56+
* with an already-filled (matching) letter. Higher overlap means a more
57+
* interlocked, less predictable layout.
58+
*
59+
* @param {import('./Grid.js').Grid} grid
60+
* @param {string} word
61+
* @param {number} x
62+
* @param {number} y
63+
* @param {string} dirName
64+
* @param {'square'|'hex'|'triangular'} lattice
65+
* @returns {{coords:Array<{x:number,y:number,ch:string}>, overlap:number}|null}
66+
*/
67+
function evaluatePlacement(grid, word, x, y, dirName, lattice) {
68+
const len = word.length;
69+
const coords = [];
70+
let overlap = 0;
71+
let cx = x;
72+
let cy = y;
73+
for (let i = 0; i < len; i++) {
74+
if (!grid.inBounds(cx, cy)) return null;
75+
const existing = grid.get(cx, cy);
76+
if (existing) {
77+
if (existing !== word[i]) return null;
78+
overlap += 1;
79+
}
80+
coords.push({ x: cx, y: cy, ch: word[i] });
81+
const next = step(lattice, cx, cy, dirName, 1);
82+
if (!next) return null;
83+
cx = next.x;
84+
cy = next.y;
85+
}
86+
if (coords.length !== len) return null;
87+
return { coords, overlap };
88+
}
389

490
/**
5-
* Attempt to place a single word along a random direction/position.
6-
* Returns the placement record or null if no valid spot found within
7-
* `tries`.
91+
* Attempt to place a single word. Rather than picking one random
92+
* direction per attempt (which biases the resulting pattern), we sample a
93+
* random starting position then evaluate ALL orientations there in a
94+
* shuffled order. Across the search we keep the best-scoring valid
95+
* placement, preferring ones that intersect existing words so the seed
96+
* words form an interlocked arrangement.
97+
*
98+
* @param {import('./Grid.js').Grid} grid
99+
* @param {string} word
100+
* @param {() => number} rng
8101
* @param {object} [opts]
9102
* @param {'square'|'hex'|'triangular'} [opts.lattice]
10103
* @param {boolean} [opts.includeBackwards]
104+
* @param {number} [opts.maxAdjacency]
105+
* @param {Map<string, Set<number>>} [opts.ownership]
106+
* @param {number} [opts.wordIndex]
11107
*/
12108
function tryPlaceWord(grid, word, rng, opts = {}, tries = 200) {
13-
const { lattice = 'square', includeBackwards = true } = opts;
109+
const {
110+
lattice = 'square',
111+
includeBackwards = true,
112+
maxAdjacency = 1,
113+
ownership = new Map(),
114+
} = opts;
115+
const seekIntersections = maxAdjacency > 0;
116+
117+
let best = null; // { coords, overlap, dirName, x, y }
118+
14119
for (let t = 0; t < tries; t++) {
15120
const x = Math.floor(rng() * grid.width);
16121
const y = Math.floor(rng() * grid.height);
17-
const dirs = latticeDirections(lattice, y, { includeBackwards });
18-
const dir = dirs[Math.floor(rng() * dirs.length)];
19-
const len = word.length;
20-
21-
// Walk the word using row-aware stepping.
22-
let ok = true;
23-
const coords = [];
24-
let cx = x;
25-
let cy = y;
26-
for (let i = 0; i < len; i++) {
27-
if (!grid.inBounds(cx, cy)) {
28-
ok = false;
29-
break;
30-
}
31-
const existing = grid.get(cx, cy);
32-
if (existing && existing !== word[i]) {
33-
ok = false;
34-
break;
122+
// Direction vectors depend on the row for hex/tri lattices.
123+
const dirs = shuffle(latticeDirections(lattice, y, { includeBackwards }).slice(), rng);
124+
for (const dir of dirs) {
125+
const res = evaluatePlacement(grid, word, x, y, dir.name, lattice);
126+
if (!res) continue;
127+
// Reject placements that touch too many existing words.
128+
const adj = countAdjacentWords(res.coords, ownership, grid);
129+
if (adj > maxAdjacency) continue;
130+
// When adjacency is disallowed entirely, never accept overlaps.
131+
if (!seekIntersections && res.overlap > 0) continue;
132+
if (best === null || res.overlap > best.overlap) {
133+
best = { coords: res.coords, overlap: res.overlap, dirName: dir.name, x, y };
134+
// An intersecting placement is exactly what we want; commit early
135+
// so later (larger) words still have room and direction stays varied.
136+
if (seekIntersections && res.overlap > 0) break;
35137
}
36-
coords.push({ x: cx, y: cy, ch: word[i] });
37-
const next = step(lattice, cx, cy, dir.name, 1);
38-
if (!next) {
39-
ok = false;
40-
break;
41-
}
42-
cx = next.x;
43-
cy = next.y;
44138
}
45-
if (!ok || coords.length !== len) continue;
139+
// Found an interlocking spot — no need to keep searching.
140+
if (seekIntersections && best && best.overlap > 0) break;
141+
}
46142

47-
for (const c of coords) {
48-
grid.set(c.x, c.y, c.ch);
49-
grid.lock(c.x, c.y);
50-
}
51-
return { word, dir: dir.name, x, y, coords };
143+
if (!best) return null;
144+
145+
for (const c of best.coords) {
146+
grid.set(c.x, c.y, c.ch);
147+
grid.lock(c.x, c.y);
52148
}
53-
return null;
149+
return { word, dir: best.dirName, x: best.x, y: best.y, coords: best.coords };
54150
}
55151

56152
/**
@@ -61,20 +157,38 @@ function tryPlaceWord(grid, word, rng, opts = {}, tries = 200) {
61157
* @param {object} [opts]
62158
* @param {'square'|'hex'|'triangular'} [opts.lattice]
63159
* @param {boolean} [opts.includeBackwards]
160+
* @param {number} [opts.maxAdjacency]
64161
*/
65162
export function placeWords(grid, words, rng = Math.random, opts = {}) {
66163
const placed = [];
67164
const failed = [];
68-
// Place longer words first (harder to fit).
165+
// Track which placed-word indices occupy each cell so we can measure how
166+
// many distinct words a candidate placement would touch.
167+
/** @type {Map<string, Set<number>>} */
168+
const ownership = new Map();
169+
// Place longer words first (harder to fit, and they form the backbone
170+
// that shorter words can interlock with).
69171
const clean = words
70172
.map((w) => normaliseText(w))
71173
.filter(Boolean)
72174
.sort((a, b) => b.length - a.length);
73175

74176
for (const w of clean) {
75-
const rec = tryPlaceWord(grid, w, rng, opts);
76-
if (rec) placed.push(rec);
77-
else failed.push(w);
177+
const wordIndex = placed.length;
178+
const rec = tryPlaceWord(grid, w, rng, { ...opts, ownership, wordIndex });
179+
if (rec) {
180+
placed.push(rec);
181+
// Record ownership of every cell this word occupies.
182+
for (const c of rec.coords) {
183+
const key = `${c.x},${c.y}`;
184+
let set = ownership.get(key);
185+
if (!set) {
186+
set = new Set();
187+
ownership.set(key, set);
188+
}
189+
set.add(wordIndex);
190+
}
191+
} else failed.push(w);
78192
}
79193
return { placed, failed };
80194
}

0 commit comments

Comments
 (0)