UNPKG

literant-search

Version:

Efficient searches for high scoring words on a Scrabble-like board using either a Trie or compressed DAWG.

255 lines (231 loc) 10.5 kB
import {chunk, chunkByColumns, stepTimer, place} from './util.js' import DirectedAcyclicWordGraph from './dawg.js' import {calculateScore, scoreRemaining, setCustomTiles} from './score.js' let dawg = null; let opts = null; const defaultOpts = {skill:5, compress: true, debug: 0}; const constrain = (low, high, value) => Math.max(Math.min(parseInt(value, 10), high), low); const from0to6 = constrain.bind(null, 0, 6); const aiSkill = (override) => from0to6(override != null? override: opts.skill); /** * Initializes the AI with a given word list and configuration options. * * @param {Array} wordList - The list of words the AI will use. Note: Must be in Upper Case. * @param {Object} options - Configuration settings for the AI. */ export function aiInit(wordList, options={}){ opts = {...defaultOpts, ...options}; opts.skill = from0to6(opts.skill); const timer = stepTimer(); dawg = new DirectedAcyclicWordGraph(wordList, debug); if(opts.compress){ if(opts.debug){ console.log(dawg.testCompression()); }else{ dawg.minimize(); } } debug("DAWG built in", timer(), "millisecs"); } export function aiSetTiles(tiles){ setCustomTiles(tiles); } /** * Finds the best move for the AI in a word game, based on it's skill level. * * @param {Array} board - The current board state as a 1D array. Represented by uppercase letters, lower case letters (for blanks), and spaces. * @param {Array} letters - The letters available for the AI to use. * @param {Array|null} opLetters - (Optional) Letters the opponent has. A non null value signals the end game as they can be infered from board and rack, and used to min-max. * @param {number|null} skillOverride - (Optional) A value between 0 and 6 for adjusting skill. At level 6 the AI will min-max the end game (where opponent's letters can be inferred). * @param {boolean} allMoves (Optional) if set all moves found will returned in a list with scores. * @returns {Object|null} - An appropriate move based on skill level, or null if none found. */ export function aiFindMove(board, letters, opLetters=null, skillOverride=null, allMoves=false){ if(! (dawg && opts)) throw Error("AI Not initialized successfully with init method."); const width = Math.sqrt(board.length); if(Math.floor(width) != width) throw Error("Invalid board passed to AI play method."); const attachPoints = findAttachPoints(board, width); if(attachPoints.length === 0) return false; const moves = {}; const lookup = createLookupTable(board, width); const start = Date.now(); attachPoints.forEach(p => { const found = findMoves(lookup, p, letters); p.found = found; }); // having opLetters signifies endgame currently and we don't want to retain letters then const usingLetterRetention = aiSkill(skillOverride) > 5 && opLetters == null; const scoredMoves = scoreAllResults(board, attachPoints, usingLetterRetention); if(allMoves){ return scoredMoves; } const result = selectMove(board, scoredMoves, letters, opLetters, skillOverride); if(opts.debug){ const nm = attachPoints.reduce((acc, cur) => acc + cur.found.length, 0); console.log(nm, "dawg moves found in ", Date.now()-start, "millisecs"); console.log("highest scoring:", result); } return result; } function debug(){ if(! opts.debug) return; console.log.apply(null, arguments); } function findAttachPoints(board, width){ const found = new Set([]); function letterHere(origin, offset){ const adjacent = origin + offset; if(adjacent < 0 || adjacent >= board.length) return 0; if(Math.abs(offset) == 1 && Math.floor(adjacent / width) != Math.floor(origin / width)) return false; return board[adjacent] != " "; } board.forEach((c, i) => { if(board[i] != " ") return; const edges = [-width, width, -1, 1]; if( edges.find(letterHere.bind(null, i))) { found.add({at: i, dir: "a"}); found.add({at: i, dir: "d"}); } }); const result = Array.from(found); if(result.length == 0){ // if there are no letters on the board create starting point const starIndex = board.findIndex(ch => ch == '★'); const startIndex = starIndex == -1? Math.floor(board.length / 2): starIndex; result.push({at: startIndex, dir: "a"}); result.push({at: startIndex, dir: "d"}); } return result; } function createLookupTable(board, width){ return { rows: chunk(board, width), cols: chunkByColumns(board, width) }; } // Returns a a constraints object for a row or column of the board defining indexes where letters are placed function getSliceConstraints(lookup, dir, px, py){ function toConstraints(slice){ return slice.reduce((acc, cur, i) => { if(cur != " ") acc[i] = cur; return acc; }, {}); } if(dir == "a") return toConstraints(lookup.rows[py]); return toConstraints(lookup.cols[px]); } // finds all moves that fit on the board function findMoves(lookup, point, letters, method){ const width = lookup.rows.length; const [px, py] = [point.at % width, Math.floor(point.at / width)]; const [pointIndex, pointRow, perpBoard] = point.dir == "a"? [px, py, lookup.cols]: [py, px, lookup.rows]; const constraints = getSliceConstraints(lookup, point.dir, px, py); const result = []; for(let i=0; i<=pointIndex; i++){ if(constraints[i-1]) continue; // if preceding tile has a letter, we cant start here. Or, it would need to be included with the word. const minLength = Math.max((pointIndex - i), 2); const maxLength = width - i; let words = dawg.findWords(letters, i, pointIndex, pointRow, minLength, maxLength, constraints, perpBoard); if(words?.length) result.push(words); } return result.flat(); } function scoreAllResults(board, attachPoints, retainLetters=false){ const boardWidth = Math.sqrt(board.length); return attachPoints.filter(ap => ap?.found.length).map(ap => { const apX = ap.at % boardWidth; const apY = Math.floor(ap.at / boardWidth); return ap.found.map(found => { const pos = ap.dir == "a"? found.at+(apY*boardWidth): (found.at*boardWidth)+apX; const score = calculateScore(board, boardWidth, pos, ap.dir, found.word, found.perp); if(retainLetters){ const retention = retentionValue(found.word); return {pos, dir: ap.dir, word: found.word, score, retention}; } return {pos, dir: ap.dir, word: found.word, score}; }); }); } const topScoring = (acc, cur) => cur.score> acc.score? cur: acc; function selectMove(board, moves, letters, opLetters, skillOverride){ if(!moves?.length) return null; const skill = aiSkill(skillOverride); if(skill >= 5){ // return best word if(skill > 5){ if(opLetters){ // at end game with opponent's letters inferred const finishingMove = findFinishingMove(board, moves, letters); if(finishingMove){ return finishingMove; // this assumes getting out is best which may not be the case } return minMax(board, moves, letters, opLetters); }else{ const retentionScoring = (acc, cur) => (cur.score-(cur.retention||0) > acc.score-(acc.retention||0))? cur: acc; const boardLetterCount = board.reduce((acc, cur) => cur == " "? acc: acc+1, 0); return moves.flat().reduce(boardLetterCount <= 60? retentionScoring: topScoring, {score:0}); } } return moves.flat().reduce(topScoring, {score:0}); } const trendTowards = [10, 15, 20, 24, 28]; const targetScore = trendTowards[skill] + Math.floor(Math.random() * ((skill+1)*3)); // find a move that's closest to the target score return moves.flat().reduce((acc, cur) => Math.abs(targetScore-cur.score) < Math.abs(targetScore-acc.score)? cur: acc, {score:0}); } // gets the letters that already exist on the board from the word in the move function getBoardLettersInMove(board, move){ const inc = move.dir == "a"? 1: Math.sqrt(board.length); return move.word.split("").filter((ch, i) => board[move.pos+i*inc] != " "); } // removed teh first instance of each letter in toRemove from the letters passed in function removeLetters(letters, toRemove){ return toRemove.split("").reduce((acc, cur) => acc.replace(cur, ""), letters); } function findFinishingMove(board, moves, letters, allMoves=false){ const out = moves.flat().filter(move => { const boardLetters = getBoardLettersInMove(board, move).length; return (move.word.length - boardLetters == letters.length); }); if(! out?.length) return null; if(allMoves) return out; return out.reduce(topScoring, {score: 0}); } function minMax(board, moves, letters, opLetters){ const timer = stepTimer(); const deltas = moves.map(ap => { const best = ap.reduce((acc, cur) => cur.score > acc.score? cur: acc, {score:0}); const nextBoardState = place(board.join("").split(""), best); const opMoves = aiFindMove(nextBoardState, opLetters, null, 5, true); const opBest = opMoves.flat().reduce(topScoring, {score: 0}); const out = findFinishingMove(nextBoardState, opMoves, opLetters, true); if(out?.length){ // if can opponent get out following turn factor that into delta const bestOut = out.reduce(topScoring, {score: 0}); const boardLetters = getBoardLettersInMove(board, best).join(""); const remainingLetters = removeLetters(letters, removeLetters(best.word, boardLetters) ); best.delta = best.score - (opBest.score + scoreRemaining(remainingLetters) * 2); }else{ best.delta = best.score - opBest.score; } return best; }); const topDelta = deltas.reduce((acc, cur) => cur.delta > acc.delta? cur: acc, {delta:-999}); if(opts.debug > 0){ const topScore = deltas.reduce(topScoring, {score:0}); debug("Delta:", topDelta, "Score:", topScore); timer("Minmax in"); } return topDelta; } // returns an approximation of how much value is lost by using the letter from the rack before the end game function retentionValue(word){ const values = { "Z": 3, // high scoring and easy to place "X": 3, // ditto "Q": 1, "S": 2, // single char suffix "R": 1, // ditto, but less common "D": 1 }; return word.split("").reduce((acc, cur) => acc + (values[cur] || 0), 0); }