Skip to content

Commit 0e5efb3

Browse files
committed
wordsearch prototype
1 parent 93988fb commit 0e5efb3

14 files changed

Lines changed: 431 additions & 60 deletions

File tree

experiments/wordsearch/index.html

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,44 @@ <h1>Markov Wordsearch</h1>
1717
<button class="mode-tab" data-mode="play">Play</button>
1818
</div>
1919

20+
<div id="panel-design">
21+
<div class="actions">
22+
<button id="btn-regen">Generate</button>
23+
<!-- <button id="btn-png" class="secondary">Export PNG</button>-->
24+
<button id="btn-txt" class="secondary">Export TXT</button>
25+
</div>
26+
<div id="status"></div>
27+
</div>
28+
29+
<div id="panel-watch" hidden>
30+
<fieldset>
31+
<legend>Watch buildout</legend>
32+
<label for="watch-speed">Auto-play speed (ms/step)</label>
33+
<input id="watch-speed" type="number" min="10" max="2000" value="120" />
34+
<div class="actions">
35+
<button id="btn-watch-step">Step</button>
36+
<button id="btn-watch-play" class="secondary">Play</button>
37+
<button id="btn-watch-pause" class="secondary">Pause</button>
38+
<button id="btn-watch-finish" class="secondary">Finish</button>
39+
<button id="btn-watch-reset" class="secondary">Reset</button>
40+
</div>
41+
<div id="watch-status"></div>
42+
</fieldset>
43+
</div>
44+
45+
<div id="panel-play" hidden>
46+
<fieldset>
47+
<legend>Play</legend>
48+
<div>Time: <span id="play-timer">00:00</span></div>
49+
<p>Click the first letter, then the last letter of a word.</p>
50+
<ul id="play-words"></ul>
51+
<div class="actions">
52+
<button id="btn-play-new">New game</button>
53+
</div>
54+
<div id="play-status"></div>
55+
</fieldset>
56+
</div>
57+
2058
<div id="config-controls">
2159
<fieldset>
2260
<legend>Grid</legend>
@@ -49,10 +87,10 @@ <h1>Markov Wordsearch</h1>
4987

5088
<label for="cfg-combiner">Combiner</label>
5189
<select id="cfg-combiner">
52-
<option value="product" selected>product (AND)</option>
90+
<option value="vote" selected>vote</option>
91+
<option value="product">product (AND)</option>
5392
<option value="sum">sum (OR)</option>
5493
<option value="max">max</option>
55-
<option value="vote">vote</option>
5694
</select>
5795

5896
<label for="cfg-sampling">Sampling</label>
@@ -76,47 +114,13 @@ <h1>Markov Wordsearch</h1>
76114
<legend>Target words</legend>
77115
<label for="cfg-words">One per line or comma-separated</label>
78116
<textarea id="cfg-words"></textarea>
117+
<label for="cfg-wordcount">
118+
Words to use (0 or blank = all; randomly sampled for large lists)
119+
</label>
120+
<input id="cfg-wordcount" type="number" min="0" max="200" value="0" />
79121
<label><input id="cfg-debug" type="checkbox" /> Highlight placed words</label>
80122
</fieldset>
81123
</div>
82-
83-
<div id="panel-design">
84-
<div class="actions">
85-
<button id="btn-regen">Generate</button>
86-
<button id="btn-png" class="secondary">Export PNG</button>
87-
<button id="btn-txt" class="secondary">Export TXT</button>
88-
</div>
89-
<div id="status"></div>
90-
</div>
91-
92-
<div id="panel-watch" hidden>
93-
<fieldset>
94-
<legend>Watch buildout</legend>
95-
<label for="watch-speed">Auto-play speed (ms/step)</label>
96-
<input id="watch-speed" type="number" min="10" max="2000" value="120" />
97-
<div class="actions">
98-
<button id="btn-watch-step">Step</button>
99-
<button id="btn-watch-play" class="secondary">Play</button>
100-
<button id="btn-watch-pause" class="secondary">Pause</button>
101-
<button id="btn-watch-finish" class="secondary">Finish</button>
102-
<button id="btn-watch-reset" class="secondary">Reset</button>
103-
</div>
104-
<div id="watch-status"></div>
105-
</fieldset>
106-
</div>
107-
108-
<div id="panel-play" hidden>
109-
<fieldset>
110-
<legend>Play</legend>
111-
<div>Time: <span id="play-timer">00:00</span></div>
112-
<p>Click the first letter, then the last letter of a word.</p>
113-
<ul id="play-words"></ul>
114-
<div class="actions">
115-
<button id="btn-play-new">New game</button>
116-
</div>
117-
<div id="play-status"></div>
118-
</fieldset>
119-
</div>
120124
</aside>
121125

122126
<main>

experiments/wordsearch/src/fill/filler.js

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { latticeDirections, readContext } from '../grid/directions.js';
1+
import { latticeDirections, readContext, readLineAround } from '../grid/directions.js';
22
import { combine } from './combiners.js';
33
import { pickNextCell } from './adjacency.js';
4+
import { buildForbiddenIndex } from '../grid/wordlist.js';
45

56
/**
67
* Select a character from a distribution.
@@ -34,6 +35,82 @@ export function select(dist, mode, rng, fallbackAlphabet) {
3435
// floating point fallback
3536
return [...dist.keys()].pop();
3637
}
38+
/**
39+
* Determine which candidate characters at (x, y) would accidentally
40+
* complete a forbidden word along any lattice direction, given the
41+
* already-filled neighbours.
42+
*
43+
* For each direction we read the filled run behind and ahead of the cell,
44+
* then for every possible split position within a window of (maxLen) we
45+
* check whether placing a character would form a forbidden word that spans
46+
* the cell. Returns a Set of characters to avoid.
47+
*
48+
* @param {import('../grid/Grid.js').Grid} grid
49+
* @param {number} x
50+
* @param {number} y
51+
* @param {Array<{name:string,dx:number,dy:number}>} dirs
52+
* @param {{set:Set<string>, maxLen:number}} forbidden
53+
* @param {'square'|'hex'|'triangular'} lattice
54+
* @returns {Set<string>}
55+
*/
56+
function forbiddenChars(grid, x, y, dirs, forbidden, lattice) {
57+
const avoid = new Set();
58+
if (!forbidden || forbidden.maxLen < 2 || forbidden.set.size === 0) return avoid;
59+
const reach = forbidden.maxLen - 1;
60+
for (const d of dirs) {
61+
const { before, after } = readLineAround(grid, x, y, d, reach, reach, lattice);
62+
// The candidate char sits between `before` and `after`. Any contiguous
63+
// substring of `${before}${candidate}${after}` that includes the
64+
// candidate and matches a forbidden word is disallowed.
65+
for (const word of forbidden.set) {
66+
const L = word.length;
67+
if (L < 2 || L > before.length + 1 + after.length) continue;
68+
// The candidate occupies index `before.length` in the combined line.
69+
// Try every alignment of `word` over the combined line that covers it.
70+
for (let start = before.length - (L - 1); start <= before.length; start++) {
71+
if (start < 0) continue;
72+
const candIdx = before.length - start; // position of candidate within word
73+
if (candIdx < 0 || candIdx >= L) continue;
74+
let ok = true;
75+
for (let i = 0; i < L; i++) {
76+
if (i === candIdx) continue; // this is the candidate slot
77+
const lineIdx = start + i; // index within combined line
78+
let ch;
79+
if (lineIdx < before.length) ch = before[lineIdx];
80+
else ch = after[lineIdx - before.length - 1];
81+
if (ch == null || ch !== word[i]) {
82+
ok = false;
83+
break;
84+
}
85+
}
86+
if (ok) avoid.add(word[candIdx]);
87+
}
88+
}
89+
}
90+
return avoid;
91+
}
92+
/**
93+
* Return a copy of `dist` with forbidden characters removed and the
94+
* remaining mass re-normalised. If everything would be removed, the
95+
* original distribution is returned unchanged (we'd rather risk a word
96+
* than fail to fill a cell).
97+
* @param {Map<string,number>} dist
98+
* @param {Set<string>} avoid
99+
* @returns {Map<string,number>}
100+
*/
101+
function pruneDistribution(dist, avoid) {
102+
if (!avoid || avoid.size === 0 || !dist || dist.size === 0) return dist;
103+
const out = new Map();
104+
let total = 0;
105+
for (const [c, p] of dist) {
106+
if (avoid.has(c)) continue;
107+
out.set(c, p);
108+
total += p;
109+
}
110+
if (out.size === 0 || total <= 0) return dist;
111+
for (const [c, p] of out) out.set(c, p / total);
112+
return out;
113+
}
37114

38115
/**
39116
* Step-by-step generator version of fillGrid. Yields after each cell
@@ -52,8 +129,10 @@ export function* fillGridSteps(grid, model, config = {}) {
52129
lattice = 'square',
53130
includeBackwards = true,
54131
reverseModel = null,
132+
words = [],
55133
} = config;
56134
const alphabet = [...model.alphabet];
135+
const forbidden = buildForbiddenIndex(words);
57136
let cell;
58137
let guard = grid.width * grid.height + 1;
59138
while ((cell = pickNextCell(grid, rng, lattice)) && guard-- > 0) {
@@ -75,7 +154,10 @@ export function* fillGridSteps(grid, model, config = {}) {
75154
}
76155
}
77156
}
78-
const combined = dists.length ? combine(dists, combiner) : model.predict('');
157+
let combined = dists.length ? combine(dists, combiner) : model.predict('');
158+
// Avoid accidentally constructing any target word in the filler.
159+
const avoid = forbiddenChars(grid, x, y, dirs, forbidden, lattice);
160+
combined = pruneDistribution(combined, avoid);
79161
const ch = select(combined, sampling, rng, alphabet);
80162
grid.set(x, y, ch);
81163
yield { x, y, ch, contexts };
@@ -101,8 +183,10 @@ export function fillGrid(grid, model, config = {}) {
101183
lattice = 'square',
102184
includeBackwards = true,
103185
reverseModel = null,
186+
words = [],
104187
} = config;
105188
const alphabet = [...model.alphabet];
189+
const forbidden = buildForbiddenIndex(words);
106190

107191
let cell;
108192
let guard = grid.width * grid.height + 1;
@@ -121,7 +205,10 @@ export function fillGrid(grid, model, config = {}) {
121205
}
122206
}
123207
// If no directional context available, fall back to unigram.
124-
const combined = dists.length ? combine(dists, combiner) : model.predict('');
208+
let combined = dists.length ? combine(dists, combiner) : model.predict('');
209+
// Avoid accidentally constructing any target word in the filler.
210+
const avoid = forbiddenChars(grid, x, y, dirs, forbidden, lattice);
211+
combined = pruneDistribution(combined, avoid);
125212
const ch = select(combined, sampling, rng, alphabet);
126213
grid.set(x, y, ch);
127214
}

experiments/wordsearch/src/generator.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Grid } from './grid/Grid.js';
44
import { placeWords } from './grid/placement.js';
55
import { fillGrid } from './fill/filler.js';
66
import { MarkovModel } from './markov/MarkovModel.js';
7+
import { selectWords } from './grid/wordlist.js';
78

89
/**
910
* @param {object} opts
@@ -29,9 +30,12 @@ export function generatePuzzle(opts) {
2930
rng = Math.random,
3031
lattice = 'square',
3132
includeBackwards = true,
33+
wordCount = 0,
3234
model: providedModel,
3335
reverseModel: providedReverseModel,
3436
} = opts;
37+
// Sample down (potentially large) word lists to the configured count.
38+
const selectedWords = selectWords(words, wordCount, rng);
3539

3640
const model = providedModel || new MarkovModel(order).train(referenceText, order);
3741
// Reverse model only matters when backward-oriented vectors participate.
@@ -40,14 +44,15 @@ export function generatePuzzle(opts) {
4044
: null;
4145

4246
const grid = new Grid(width, height);
43-
const placement = placeWords(grid, words, rng, { lattice, includeBackwards });
47+
const placement = placeWords(grid, selectedWords, rng, { lattice, includeBackwards });
4448
fillGrid(grid, model, {
4549
combiner,
4650
sampling,
4751
rng,
4852
lattice,
4953
includeBackwards,
5054
reverseModel,
55+
words: selectedWords,
5156
});
5257
grid.lattice = lattice;
5358

@@ -68,14 +73,17 @@ export function preparePuzzle(opts) {
6873
rng = Math.random,
6974
lattice = 'square',
7075
includeBackwards = true,
76+
wordCount = 0,
7177
model: providedModel,
7278
reverseModel: providedReverseModel,
7379
} = opts;
80+
// Sample down (potentially large) word lists to the configured count.
81+
const selectedWords = selectWords(words, wordCount, rng);
7482
const model = providedModel || new MarkovModel(order).train(referenceText, order);
7583
const reverseModel = includeBackwards
7684
? providedReverseModel || new MarkovModel(order).train(referenceText, order, { reverse: true })
7785
: null;
7886
const grid = new Grid(width, height);
79-
const placement = placeWords(grid, words, rng, { lattice, includeBackwards });
80-
return { grid, placement, model, reverseModel };
87+
const placement = placeWords(grid, selectedWords, rng, { lattice, includeBackwards });
88+
return { grid, placement, model, reverseModel, selectedWords };
8189
}

experiments/wordsearch/src/grid/directions.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,53 @@ export function readContext(grid, x, y, d, order, lattice = 'square') {
179179
// chars are ordered nearest-first; reverse for reading order.
180180
return chars.reverse().join('');
181181
}
182+
/**
183+
* Read up to `back` filled characters behind (x, y) and up to `fwd` filled
184+
* characters ahead of (x, y) along direction d, accounting for row-parity
185+
* offsets in hex/triangular lattices.
186+
*
187+
* Returns { before, after } strings in reading order. The cell (x, y)
188+
* itself is NOT included.
189+
*
190+
* @param {import('./Grid.js').Grid} grid
191+
* @param {number} x
192+
* @param {number} y
193+
* @param {{dx:number,dy:number,name?:string}} d
194+
* @param {number} back
195+
* @param {number} fwd
196+
* @param {'square'|'hex'|'triangular'} [lattice]
197+
* @returns {{before:string, after:string}}
198+
*/
199+
export function readLineAround(grid, x, y, d, back, fwd, lattice = 'square') {
200+
const before = [];
201+
const after = [];
202+
// Walk backwards (opposite of d).
203+
let cx = x;
204+
let cy = y;
205+
for (let k = 0; k < back; k++) {
206+
const vecs =
207+
lattice === 'hex' ? hexVectors(cy) : lattice === 'triangular' ? triVectors(cy) : DIRECTIONS;
208+
const v = d.name && vecs[d.name] ? vecs[d.name] : d;
209+
cx = cx - v.dx;
210+
cy = cy - v.dy;
211+
if (!grid.inBounds(cx, cy)) break;
212+
const ch = grid.get(cx, cy);
213+
if (!ch) break;
214+
before.push(ch);
215+
}
216+
// Walk forwards (along d).
217+
cx = x;
218+
cy = y;
219+
for (let k = 0; k < fwd; k++) {
220+
const vecs =
221+
lattice === 'hex' ? hexVectors(cy) : lattice === 'triangular' ? triVectors(cy) : DIRECTIONS;
222+
const v = d.name && vecs[d.name] ? vecs[d.name] : d;
223+
cx = cx + v.dx;
224+
cy = cy + v.dy;
225+
if (!grid.inBounds(cx, cy)) break;
226+
const ch = grid.get(cx, cy);
227+
if (!ch) break;
228+
after.push(ch);
229+
}
230+
return { before: before.reverse().join(''), after: after.join('') };
231+
}

0 commit comments

Comments
 (0)