1- import { latticeDirections , readContext } from '../grid/directions.js' ;
1+ import { latticeDirections , readContext , readLineAround } from '../grid/directions.js' ;
22import { combine } from './combiners.js' ;
33import { 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 }
0 commit comments