-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.js
More file actions
238 lines (206 loc) · 7.07 KB
/
Copy pathengine.js
File metadata and controls
238 lines (206 loc) · 7.07 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
// TileLoom game engine — original rules constants and pure game logic.
// Board layout, tile distribution, and values are original to this project.
'use strict';
const SIZE = 15;
const CENTER = 7;
const RACK_SIZE = 7;
const BINGO_BONUS = 40;
const BLANK = '?';
// Original tile set: 102 tiles including 2 blanks.
// { letter: [count, value] }
const TILES = {
A: [9, 1], B: [2, 3], C: [2, 3], D: [4, 2], E: [12, 1], F: [2, 4],
G: [3, 2], H: [3, 3], I: [8, 1], J: [1, 9], K: [1, 6], L: [4, 1],
M: [3, 2], N: [6, 1], O: [8, 1], P: [2, 3], Q: [1, 11], R: [6, 1],
S: [5, 1], T: [6, 1], U: [4, 1], V: [2, 4], W: [2, 4], X: [1, 9],
Y: [2, 4], Z: [1, 11], '?': [2, 0],
};
function letterValue(letter, isBlank) {
return isBlank ? 0 : TILES[letter][1];
}
// Original premium-square layout. Defined for the top-left quadrant
// (rows 0-7, cols 0-7) and mirrored four ways; center is a 2W star.
const QUADRANT = {
'0,5': '3W', '5,0': '3W',
'1,1': '2W', '4,4': '2W',
'0,0': '3L', '3,6': '3L', '6,3': '3L',
'5,5': '2L', '1,4': '2L', '4,1': '2L', '7,5': '2L', '5,7': '2L',
};
const PREMIUMS = (() => {
const map = {};
for (const key in QUADRANT) {
const [r, c] = key.split(',').map(Number);
const type = QUADRANT[key];
for (const rr of new Set([r, SIZE - 1 - r])) {
for (const cc of new Set([c, SIZE - 1 - c])) {
map[rr + ',' + cc] = type;
}
}
}
map[CENTER + ',' + CENTER] = '2W';
return map;
})();
function premiumAt(r, c) {
return PREMIUMS[r + ',' + c] || null;
}
// Dictionary
const DICT = new Set(WORD_LIST.split(' '));
function isWord(w) {
return DICT.has(w.toLowerCase());
}
// --- Bag ---
function makeBag(rng) {
const bag = [];
for (const letter in TILES) {
for (let i = 0; i < TILES[letter][0]; i++) bag.push(letter);
}
// Fisher-Yates shuffle
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor((rng ? rng() : Math.random()) * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
return bag;
}
function drawTiles(bag, n) {
return bag.splice(0, Math.min(n, bag.length));
}
// --- Board ---
function makeBoard() {
return Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
}
function boardEmpty(board) {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) if (board[r][c]) return false;
}
return true;
}
// --- Move validation & scoring ---
// placements: [{ r, c, letter, blank }] — letter is the chosen letter (A-Z),
// blank is true if the tile is a blank standing in for that letter.
// Returns { valid, error?, words?: [{word, score}], total?, bingo? }
function validateMove(board, placements) {
if (!placements.length) return { valid: false, error: 'Place at least one tile.' };
// No duplicates, all cells empty and in bounds
const seen = new Set();
for (const p of placements) {
if (p.r < 0 || p.r >= SIZE || p.c < 0 || p.c >= SIZE) {
return { valid: false, error: 'Tile out of bounds.' };
}
const key = p.r + ',' + p.c;
if (seen.has(key)) return { valid: false, error: 'Two tiles on one square.' };
seen.add(key);
if (board[p.r][p.c]) return { valid: false, error: 'Square already occupied.' };
}
const sameRow = placements.every(p => p.r === placements[0].r);
const sameCol = placements.every(p => p.c === placements[0].c);
if (!sameRow && !sameCol) {
return { valid: false, error: 'Tiles must be in a single row or column.' };
}
const firstMove = boardEmpty(board);
// Temp board with placements applied
const temp = board.map(row => row.slice());
for (const p of placements) {
temp[p.r][p.c] = { letter: p.letter, blank: !!p.blank, placed: true };
}
// Direction: prefer the axis where the word extends; single tile picks whichever forms a word
let dir;
if (placements.length === 1) {
const { r, c } = placements[0];
const horiz = (c > 0 && temp[r][c - 1]) || (c < SIZE - 1 && temp[r][c + 1]);
dir = horiz ? 'H' : 'V';
} else {
dir = sameRow ? 'H' : 'V';
}
const wordAt = (r, c, d) => {
// Expand to the start of the word through (r,c) in direction d
let sr = r, sc = c;
while (true) {
const pr = d === 'H' ? sr : sr - 1;
const pc = d === 'H' ? sc - 1 : sc;
if (pr < 0 || pc < 0 || !temp[pr][pc]) break;
sr = pr; sc = pc;
}
const cells = [];
let cr = sr, cc = sc;
while (cr < SIZE && cc < SIZE && temp[cr][cc]) {
cells.push({ r: cr, c: cc, tile: temp[cr][cc] });
if (d === 'H') cc++; else cr++;
}
return cells;
};
// Main word must cover every placement with no gaps
const main = wordAt(placements[0].r, placements[0].c, dir);
const mainKeys = new Set(main.map(x => x.r + ',' + x.c));
for (const p of placements) {
if (!mainKeys.has(p.r + ',' + p.c)) {
return { valid: false, error: 'Tiles must form one connected word (no gaps).' };
}
}
if (firstMove) {
if (!mainKeys.has(CENTER + ',' + CENTER)) {
return { valid: false, error: 'First word must cross the center star.' };
}
if (main.length < 2) {
return { valid: false, error: 'First word needs at least two letters.' };
}
} else {
// Must connect to existing tiles: main word longer than placements, or a cross word forms
let connects = main.length > placements.length;
if (!connects) {
for (const p of placements) {
const cross = wordAt(p.r, p.c, dir === 'H' ? 'V' : 'H');
if (cross.length > 1) { connects = true; break; }
}
}
if (!connects) {
return { valid: false, error: 'New tiles must connect to tiles on the board.' };
}
}
// Collect all words formed (length >= 2)
const formed = [];
if (main.length >= 2) formed.push(main);
for (const p of placements) {
const cross = wordAt(p.r, p.c, dir === 'H' ? 'V' : 'H');
if (cross.length >= 2) formed.push(cross);
}
if (!formed.length) {
return { valid: false, error: 'A word needs at least two letters.' };
}
// Validate against dictionary and score
const scoreWord = (cells) => {
let sum = 0, mult = 1;
for (const { r, c, tile } of cells) {
let val = letterValue(tile.letter, tile.blank);
if (tile.placed) {
const prem = premiumAt(r, c);
if (prem === '2L') val *= 2;
else if (prem === '3L') val *= 3;
else if (prem === '2W') mult *= 2;
else if (prem === '3W') mult *= 3;
}
sum += val;
}
return sum * mult;
};
const words = [];
for (const cells of formed) {
const word = cells.map(x => x.tile.letter).join('');
if (!isWord(word)) {
return { valid: false, error: '"' + word + '" is not in the dictionary.', badWord: word };
}
words.push({ word, score: scoreWord(cells) });
}
let total = words.reduce((s, w) => s + w.score, 0);
const bingo = placements.length === RACK_SIZE;
if (bingo) total += BINGO_BONUS;
return { valid: true, words, total, bingo };
}
// Apply a validated move to the board (mutates board)
function applyMove(board, placements) {
for (const p of placements) {
board[p.r][p.c] = { letter: p.letter, blank: !!p.blank };
}
}
function rackValue(rack) {
return rack.reduce((s, l) => s + TILES[l][1], 0);
}