1
0
anagram-games/www/crossword/crossword.js

408 lines
16 KiB
JavaScript

'use strict';
class Clue {
constructor(answer, row, col, horizontal) {
this.answer = answer;
this.row = row;
this.col = col;
this.horizontal = horizontal;
this.solved = false;
this.length = answer.length;
}
get afterRow() {
if (this.horizontal) {
return this.row + 1;
} else {
return this.row + this.length;
}
}
get afterCol() {
if (this.horizontal) {
return this.col + this.length;
} else {
return this.col + 1;
}
}
static fromFragment(fragment, sortedLetters) {
let horizontal = fragment[0] == 'H';
let [row, col, length, permutation] = fragment.substring(1).split(',');
let answer = '';
permutation = parseInt(permutation, 36);
for (let i = 0; i < length; i++) {
answer = sortedLetters[permutation % sortedLetters.length] + answer;
permutation = Math.floor(permutation / sortedLetters.length);
}
return new Clue(answer, Number(row), Number(col), horizontal);
}
toFragment(sortedLetters) {
let permutation = 0;
this.answer.split('').forEach(letter => {
permutation *= sortedLetters.length;
permutation += sortedLetters.indexOf(letter);
});
return (this.horizontal ? 'H' : 'V') + this.row + ',' + this.col
+ ',' + this.length + ',' + permutation.toString(36);
}
get letterPositions() {
let res = [];
if (this.horizontal) {
for (let i = 0; i < this.length; i++) {
res.push({'letter': this.answer[i],
'row': this.row, 'col': this.col + i});
}
} else {
for (let i = 0; i < this.length; i++) {
res.push({'letter': this.answer[i],
'row': this.row + i, 'col': this.col});
}
}
return res;
}
get beforePosition() {
if (this.horizontal) {
return {'row': this.row, 'col': this.col-1};
} else {
return {'row': this.row-1, 'col': this.col};
}
}
get afterPosition() {
if (this.horizontal) {
return {'row': this.row, 'col': this.col + this.length};
} else {
return {'row': this.row + this.length, 'col': this.col};
}
}
get adjacentPositions() {
if (this.horizontal) {
return this.letterPositions.map(pos => [
{'row': pos.row-1, 'col': pos.col},
{'row': pos.row+1, 'col': pos.col},
]);
} else {
return this.letterPositions.map(pos => [
{'row': pos.row, 'col': pos.col-1},
{'row': pos.row, 'col': pos.col+1},
]);
}
}
}
class CrosswordPuzzle {
constructor(clues, letters) {
this.clues = clues === undefined ? [] : clues;
this.letters = clues === undefined ? new Map() : letters;
}
withClue(clue) {
// TODO Check if valid to add clue?
let newClues = this.clues.slice();
newClues.push(clue);
let newLetters = new Map(this.letters);
clue.letterPositions.forEach(pos => {
let key = pos.row + ';' + pos.col;
let baseDir = clue.horizontal ? 'H' : 'V';
let dir = newLetters.has(key) && newLetters.get(key).dir != baseDir ? 'B' : baseDir;
newLetters.set(key, {'letter': pos.letter, 'dir': dir});
});
return new CrosswordPuzzle(newClues, newLetters);
}
get normalizedClues() {
let rowOffset = -Math.min(...this.clues.map(clue => clue.row));
let colOffset = -Math.min(...this.clues.map(clue => clue.col));
let numRows = rowOffset+Math.max(...this.clues.map(clue => clue.afterRow));
let numCols = colOffset+Math.max(...this.clues.map(clue => clue.afterCol));
// If puzzle is too wide, flip it.
if (numRows < numCols && numCols > 15) {
return this.clues
.map(clue => new Clue(clue.answer,
clue.col+colOffset,
clue.row+rowOffset,
!clue.horizontal));
} else {
return this.clues
.map(clue => new Clue(clue.answer,
clue.row+rowOffset,
clue.col+colOffset,
clue.horizontal));
}
}
}
class Crossword {
constructor(letters, clues, minGuessLength, allTopWords) {
this.minGuessLength = minGuessLength;
this.clues = clues;
this.unansweredClues = this.clues;
this.guesses = new Set();
this.numLetters = letters.length;
this.availableLetters = letters.split('').sort();
this.allTopWords = allTopWords !== undefined ? allTopWords :
new Set(topWords
.map(topWord => topWord.word)
.filter(word => this.isValidGuess(word)));
this.numRows = Math.max(...clues.map(clue => clue.afterRow));
this.numCols = Math.max(...clues.map(clue => clue.afterCol));
}
static fromFragment(fragment) {
try {
if (!fragment.includes('|')) return null;
let [minGuessLength, sortedLetters, clueFragments] = fragment.split('|');
let clues = clueFragments
.split(';')
.map(f => Clue.fromFragment(f, sortedLetters));
return new Crossword(sortedLetters, clues, Number(minGuessLength));
} catch (e) {
return null;
}
}
get fragment() {
let sortedLetters = this.availableLetters.sort();
return this.minGuessLength + '|' + sortedLetters.join('') + '|'
+ this.clues.map(clue => clue.toFragment(sortedLetters)).join(';');
}
static isMatchingWord(word, sortedLetters) {
let sortedWord = word.split('').sort();
let i = 0;
for (let idx in sortedWord) {
while (sortedWord[idx] != sortedLetters[i++]) {
if (i >= sortedLetters.length) return false;
}
}
return true;
}
static generateDefault() {
return new Crossword('there', [
new Clue('there', 2, 0, true),
new Clue('the', 0, 2, false),
new Clue('here', 2, 1, false),
]);
}
static generateRandom(settings) {
if (settings === null) return null;
let maxIters = 1000;
let iters = 0;
let regExp = new RegExp('^[a-z]{' + settings.min_word_length + ','
+ settings.max_word_length + '}$');
let word;
let sortedLetters;
let matchingTopWords;
let minMatches = Math.max(settings.min_matches, settings.num_clues);
if (settings.max_matches < minMatches) return null;
do {
do {
word = topWords[1000+getRandomInt(30000)].word;
if (iters++ > maxIters) return null;
} while (!regExp.test(word) || !isValidWord(word));
sortedLetters = word.split('').sort();
let preciseRegExp = new RegExp('^['
+ [...new Set(sortedLetters)].join('')
+ ']{' + settings.min_guess_length + ',}$');
matchingTopWords =
topWords
.filter(topWord => preciseRegExp.test(topWord.word)
&& Crossword.isMatchingWord(topWord.word,
sortedLetters)
&& isValidWord(topWord.word));
} while(matchingTopWords.length < minMatches
|| matchingTopWords.length > settings.max_matches);
let allMatchingWords = new Set(matchingTopWords.map(topWord => topWord.word));
// Sort topWords by length descending.
matchingTopWords = [...new Set(matchingTopWords.map(topWord => topWord.word.length))].sort((a,b) => b-a).flatMap(wordLen => matchingTopWords.filter(topWord => topWord.word.length == wordLen));
function possibleCluesForCrossword(crossword, clueWord) {
let clues = crossword.clues;
let letters = crossword.letters;
let possibleCluesForWord = [];
let maxIntersections = 0;
if (clues.some(clue => clue.answer == clueWord)) {
return {'maxIntersections': 0, 'options': []};
}
for (let attachIdx = 0; attachIdx < clueWord.length; attachIdx++) {
let attachLetter = clueWord[attachIdx];
clues.forEach(clue => {
for (let clueIdx in clue.answer) {
clueIdx = Number(clueIdx);
if (clue.answer[clueIdx] != attachLetter) continue;
let newClue;
if (clue.horizontal) {
newClue = new Clue(clueWord,
clue.row-attachIdx,
clue.col+clueIdx,
false);
} else {
newClue = new Clue(clueWord,
clue.row+clueIdx,
clue.col-attachIdx,
true);
}
// TODO Allow two letter words?
if ([newClue.beforePosition, newClue.afterPosition].some(pos => letters.has(pos.row + ';' + pos.col))) {
continue;
}
let dir = newClue.horizontal ? 'H' : 'V';
let adj = newClue.adjacentPositions;
let cluePos = newClue.letterPositions;
let lastHasLetter = false;
let invalid = false;
newClue.intersections = 0;
for (let i = 0; i < cluePos.length; i++) {
let pos = cluePos[i];
let key = pos.row + ';' + pos.col;
let thisHasLetter = letters.has(key);
if (thisHasLetter) {
newClue.intersections++;
if (lastHasLetter
|| letters.get(key).letter != pos.letter) {
invalid = true;
break;
}
}
else if (adj[i].some(pos => {
let key = pos.row + ';' + pos.col;
return letters.has(key);
})) {
invalid = true;
break;
}
if (adj[i].some(pos => {
let key = pos.row + ';' + pos.col;
return letters.has(key) && letters.get(key) == dir;
})) {
invalid = true;
break;
}
lastHasLetter = thisHasLetter;
}
if (!invalid) {
possibleCluesForWord.push(newClue);
maxIntersections = Math.max(maxIntersections,
newClue.intersections);
}
}
});
}
return {'maxIntersections': maxIntersections,
'options': possibleCluesForWord};
}
function tryAugmentCrosswords(crosswords) {
let defaultResult = null;
for (let topWordsIdx in matchingTopWords) {
let clueWord = matchingTopWords[topWordsIdx].word;
let possibleClues = crosswords.map(crossword => {
return {'baseCrossword': crossword,
'possibleClues': possibleCluesForCrossword(
crossword, clueWord)};
});
if (possibleClues.some(possibleCluesObj => possibleCluesObj.possibleClues.maxIntersections > 1)) {
let maxIntersections = Math.max(...possibleClues.map(crosswordOptions => crosswordOptions.possibleClues.maxIntersections));
possibleClues =
possibleClues
.filter(crosswordOptions => crosswordOptions.possibleClues.maxIntersections
== maxIntersections)
.flatMap(crosswordOptions => crosswordOptions.possibleClues.options
.map(option => {
return {'baseCrossword': crosswordOptions.baseCrossword,
'newClue': option};
}).filter(possibleClue => possibleClue.newClue.intersections
== maxIntersections));
// TODO Other considerations for selecting best crossword?
let selectedClue = possibleClues[getRandomInt(possibleClues.length)];
return selectedClue.baseCrossword.withClue(selectedClue.newClue);
} else if (defaultResult === null && possibleClues.some(crosswordOptions => crosswordOptions.possibleClues.options.length > 0)) {
defaultResult = possibleClues;
}
}
return defaultResult;
}
function tryAugmentCrossword(crossword, maxNewWords) {
let crosswords = [crossword];
for (let i = 0; i < maxNewWords; i++) {
let maybeCrossword = tryAugmentCrosswords(crosswords);
if (maybeCrossword instanceof CrosswordPuzzle) {
return maybeCrossword;
} else if (maybeCrossword === null) {
return null;
} else {
crosswords = maybeCrossword
.flatMap(crosswordOptions =>
crosswordOptions.possibleClues.options
.map(newClue => crosswordOptions
.baseCrossword
.withClue(newClue)));
}
}
return crosswords[getRandomInt(crosswords.length)];
}
let crossword = new CrosswordPuzzle()
.withClue(new Clue(word, 0, 0, getRandomBool()));
while (crossword.clues.length < settings.num_clues) {
crossword = tryAugmentCrossword(crossword, Math.min(4, settings.num_clues - crossword.clues.length));
if (crossword === null) return null;
}
return new Crossword(sortedLetters.join(''), crossword.normalizedClues,
settings.min_guess_length, allMatchingWords);
}
isValidGuess(guess) {
if (guess.length < this.minGuessLength) return false;
let availableLetters = this.availableLetters.slice();
for (let idx in guess) {
let letter = guess[idx];
let letterIdx = availableLetters.indexOf(letter);
if (letterIdx > -1) {
availableLetters.splice(letterIdx, 1);
} else {
return false;
}
}
return isValidWord(guess);
}
get won() {
return this.unansweredClues.length == 0;
}
makeGuess(guess) {
if (!this.isValidGuess(guess) || this.guesses.has(guess)) return null;
this.guesses.add(guess);
let cluesAnswered = this.unansweredClues
.filter(clue => clue.answer == guess);
cluesAnswered.forEach(clue => clue.solved = true);
this.unansweredClues = this.unansweredClues
.filter(clue => !clue.solved);
return cluesAnswered;
}
}