└── README.md /README.md: -------------------------------------------------------------------------------- 1 | @@ -0,0 +1,587 @@ 2 | (function () { 3 | // Bot flags. 4 | var EVALUATE_ONLY = false; 5 | var AUTO_RETRY = false; 6 | 7 | // Search constants. 8 | var SEARCH_DEPTH = 4; 9 | var SEARCH_TIME = 50; 10 | var RETRY_TIME = 1000; 11 | var ACCEPT_DEFEAT_VALUE = -999999; 12 | 13 | // Evaluation constants. 14 | var NUM_EMPTY_WEIGHT = 5; 15 | var ADJ_DIFF_WEIGHT = -0.5; 16 | var INSULATION_WEIGHT = -2; 17 | var POSITION_WEIGHT = 0.04; 18 | var POSITION_VALUE = [ 19 | 0, 0, 0, 10, 20 | 0, 0, 0, 15, 21 | 0, 0, -5, 20, 22 | 10, 15, 20, 50 23 | ]; 24 | var LOG2 = {}; 25 | for (var i = 0 ; i < 20; i++) { 26 | LOG2[1 << i] = i; 27 | } 28 | 29 | // Game constants. 30 | var GRID_SIZE = 4; 31 | var PROB_2 = 0.9; 32 | 33 | // Move constants. 34 | // drow: delta along row axis 35 | // dcol: delta along column axis 36 | // dir: iteration direction for correct merging 37 | // keyCode: key code to send 38 | // key: key name to send 39 | var MOVE_UP = { 40 | drow: -1, 41 | dcol: 0, 42 | dir: 0, 43 | keyCode: 38, 44 | key: 'Up' 45 | }; 46 | var MOVE_DOWN = { 47 | drow: 1, 48 | dcol: 0, 49 | dir: 1, 50 | keyCode: 40, 51 | key: 'Down' 52 | }; 53 | var MOVE_LEFT = { 54 | drow: 0, 55 | dcol: -1, 56 | dir: 0, 57 | keyCode: 37, 58 | key: 'Left' 59 | }; 60 | var MOVE_RIGHT = { 61 | drow: 0, 62 | dcol: 1, 63 | dir: 1, 64 | keyCode: 39, 65 | key: 'Right' 66 | }; 67 | 68 | // If EVALUATE_ONLY flag is not set, play the game. If the flag is set (for 69 | // development purposes), just print detailed evaluation output. 70 | if (EVALUATE_ONLY) { 71 | var grid = getGrid(); 72 | print(grid); 73 | evaluate(grid, true); 74 | } 75 | else { 76 | setInterval(nextMove, SEARCH_TIME); 77 | } 78 | 79 | // Press continue to keep playing if we win the game. 80 | setInterval(function() { 81 | if (gameWon()) { 82 | keepPlaying(); 83 | } 84 | }, RETRY_TIME); 85 | 86 | // If AUTO_RETRY flag is set, print statistics and automatically retry after 87 | // losses. 88 | if (AUTO_RETRY) { 89 | var games = 0; 90 | var bestScore = 0; 91 | var averageScore = 0; 92 | var bestLargestTile = 0; 93 | var averageLargestTile = 0; 94 | 95 | setInterval(function() { 96 | if (gameLost()) { 97 | var score = getScore(); 98 | bestScore = Math.max(bestScore, score); 99 | 100 | var grid = getGrid(); 101 | var largestTile = 0; 102 | for (var i = 0; i < grid.length; i++) { 103 | largestTile = Math.max(largestTile, grid[i]); 104 | } 105 | bestLargestTile = Math.max(bestLargestTile, largestTile); 106 | 107 | averageScore = (averageScore * games + score) / (games + 1); 108 | averageLargestTile = (averageLargestTile * games + largestTile) / (games + 1); 109 | games++; 110 | 111 | console.log('Game ' + games + '\n' + 112 | 'Score ' + score + '\n' + 113 | 'Largest tile ' + largestTile + '\n' + 114 | 'Average score ' + Math.round(averageScore) + '\n' + 115 | 'Average largest tile ' + Math.round(averageLargestTile) + '\n' + 116 | 'Best score ' + bestScore + '\n' + 117 | 'Best largest tile ' + bestLargestTile + '\n' + 118 | '\n'); 119 | 120 | search.table = {}; 121 | 122 | if (AUTO_RETRY) 123 | tryAgain(); 124 | } 125 | }, RETRY_TIME); 126 | } 127 | 128 | /** 129 | * Chooses and the next move and plays it. 130 | */ 131 | function nextMove() { 132 | var grid = getGrid(); 133 | var move = search(grid, SEARCH_DEPTH, Number.NEGATIVE_INFINITY, true); 134 | pressKey(move); 135 | } 136 | 137 | /** 138 | * Searches for the best move with depth-first search. 139 | * @param grid: flat array representation of game grid. 140 | * @param depth: search tree depth, where leaves are at depth 0. 141 | * @param alpha: lower bound on search value. 142 | * @param root: whether to treat the node as the root node. 143 | * @return best move at root nodes, value of best move at other nodes. 144 | */ 145 | function search(grid, depth, alpha, root) { 146 | if (depth <= 0) { 147 | return evaluate(grid); 148 | } 149 | 150 | if (!search.table) { 151 | search.table = {}; 152 | } 153 | 154 | // Look up game grid in the transposition table. 155 | var key = getGridKey(grid); 156 | var entry = search.table[key]; 157 | if (entry && entry.depth >= depth && (!entry.isBound || entry.value <= alpha)) { 158 | return root ? entry.move : entry.value; 159 | } 160 | 161 | // If there was a transposition entry and its value couldn't be used, 162 | // at least move its best move to the front of the current move list. 163 | var moves = [ MOVE_RIGHT, MOVE_DOWN, MOVE_LEFT, MOVE_UP ]; 164 | if (entry) { 165 | var index = moves.indexOf(entry.move); 166 | var temp = moves[index]; 167 | moves[index] = moves[0]; 168 | moves[0] = temp; 169 | } 170 | 171 | var bestMove = undefined; 172 | var alphaImproved = false; 173 | 174 | for (var i = 0; i < moves.length; i++) { 175 | var copyGrid = copy(grid); 176 | var move = moves[i]; 177 | 178 | if (make(copyGrid, move)) { 179 | bestMove = bestMove || move; 180 | var value = Number.POSITIVE_INFINITY; 181 | 182 | // Try to put a 2 in each free square. Don't bother with 4s because 183 | // it doesn't seem to make any significant difference. Iterate from 184 | // the bottom right because that's the corner favoured; try to get 185 | // a minimum value early to exit early from the loop. 186 | for (var j = copyGrid.length - 1; j >= 0 && value > alpha; j--) { 187 | if (!copyGrid[j]) { 188 | copyGrid[j] = 2; 189 | value = Math.min(value, search(copyGrid, depth - 1, alpha)); 190 | copyGrid[j] = 0; 191 | } 192 | } 193 | 194 | if (value > alpha) { 195 | alpha = value; 196 | bestMove = move; 197 | alphaImproved = true; 198 | } 199 | } 200 | } 201 | 202 | if (!bestMove) { 203 | return root ? MOVE_LEFT : ACCEPT_DEFEAT_VALUE + evaluate(grid); 204 | } 205 | 206 | // Store search results in the transposition table. 207 | search.table[key] = { 208 | depth: depth, 209 | value: alpha, 210 | move: bestMove, 211 | isBound: !alphaImproved 212 | }; 213 | 214 | return root ? bestMove : alpha; 215 | } 216 | 217 | /** 218 | * Evaluates the given grid state. 219 | * @param grid: flat array representation of game grid. 220 | * @param logging: whether to log evaluation computation. 221 | * @return estimated value of grid state. 222 | */ 223 | function evaluate(grid, logging) { 224 | var value = 0; 225 | 226 | var positionValue = 0; 227 | var adjDiffValue = 0; 228 | var insulationValue = 0; 229 | var numEmpty = 0; 230 | 231 | for (var r = 0; r < GRID_SIZE; r++) { 232 | for (var c = 0; c < GRID_SIZE; c++) { 233 | var tile = get(grid, r, c); 234 | if (!tile) { 235 | numEmpty++; 236 | continue; 237 | } 238 | positionValue += tile * POSITION_VALUE[r * GRID_SIZE + c]; 239 | 240 | // Perform pairwise comparisons. 241 | if (c < GRID_SIZE - 1) { 242 | var adjTile = get(grid, r, c + 1); 243 | if (adjTile) { 244 | adjDiffValue += levelDifference(tile, adjTile) * Math.log(tile + adjTile); 245 | 246 | // Perform triplet comparisons. 247 | if (c < GRID_SIZE - 2) { 248 | var thirdTile = get(grid, r, c + 2); 249 | if (thirdTile && levelDifference(tile, thirdTile) <= 1.1) { 250 | var smallerTile = Math.min(tile, thirdTile); 251 | insulationValue += levelDifference(smallerTile, adjTile) * Math.log(smallerTile); 252 | } 253 | } 254 | } 255 | } 256 | 257 | // Perform pairwise comparisons. 258 | if (r < GRID_SIZE - 1) { 259 | adjTile = get(grid, r + 1, c); 260 | if (adjTile) { 261 | adjDiffValue += levelDifference(tile, adjTile) * Math.log(tile + adjTile); 262 | 263 | // Perform triplet comparisons. 264 | if (c < GRID_SIZE - 2) { 265 | var thirdTile = get(grid, r + 2, c); 266 | if (thirdTile && levelDifference(tile, thirdTile) <= 1.1) { 267 | var smallerTile = Math.min(tile, thirdTile); 268 | insulationValue += levelDifference(smallerTile, adjTile) * Math.log(smallerTile); 269 | } 270 | } 271 | } 272 | } 273 | } 274 | } 275 | 276 | // Equation for log-like curve that starts at 0, ramps up quickly up 277 | // to 10 at numEmpty = 5, and levels off nearly completed after that. 278 | var numEmptyValue = 11.12249 + (0.05735587 - 11.12249) / (1 + Math.pow((numEmpty / 2.480941), 2.717769)); 279 | 280 | value += POSITION_WEIGHT * positionValue; 281 | value += NUM_EMPTY_WEIGHT * numEmptyValue; 282 | value += ADJ_DIFF_WEIGHT * adjDiffValue; 283 | value += INSULATION_WEIGHT * insulationValue; 284 | 285 | if (logging) { 286 | console.log('EVALUATION ' + value + '\n' + 287 | ' position ' + (POSITION_WEIGHT * positionValue) + '\n' + 288 | ' numEmpty ' + (NUM_EMPTY_WEIGHT * numEmptyValue) + '\n' + 289 | ' adjDiff ' + (ADJ_DIFF_WEIGHT * adjDiffValue) + '\n' + 290 | ' insulation ' + (INSULATION_WEIGHT * insulationValue) + '\n' 291 | ); 292 | } 293 | 294 | return value; 295 | } 296 | 297 | /** 298 | * Computes the stack level difference between two tiles. 299 | * @param tile1: first tile value. 300 | * @param tile2: second tile value. 301 | * @return stack level difference between two given tiles. 302 | */ 303 | function levelDifference(tile1, tile2) { 304 | return tile1 > tile2 ? LOG2[tile1] - LOG2[tile2] : LOG2[tile2] - LOG2[tile1]; 305 | } 306 | 307 | /** 308 | * Returns the tile value in the grid for a given position. 309 | * @param grid: flat array representation of game grid. 310 | * @param row: position row. 311 | * @param col: position column. 312 | * @return tile value in the grid for given position. 313 | */ 314 | function get(grid, row, col) { 315 | return grid[row * GRID_SIZE + col]; 316 | } 317 | 318 | /** 319 | * Sets the tile value in the grid for a given position. 320 | * @param grid: flat array representation of game grid. 321 | * @param row: position row. 322 | * @param col: position column. 323 | * @param tile: new tile value to assign. 324 | */ 325 | function set(grid, row, col, tile) { 326 | grid[row * GRID_SIZE + col] = tile; 327 | } 328 | 329 | /** 330 | * Prints the given grid to the console. 331 | * @param grid: flat array representation of game grid. 332 | */ 333 | function print(grid) { 334 | function pad(str, len) { 335 | len -= str.length; 336 | while (len-- > 0) 337 | str = ' ' + str; 338 | return str; 339 | } 340 | 341 | var result = ''; 342 | for (var r = 0; r < GRID_SIZE; r++) { 343 | for (var c = 0; c < GRID_SIZE; c++) { 344 | var tile = get(grid, r, c); 345 | result += tile ? pad(tile + '', 5) : ' .'; 346 | } 347 | result += '\n'; 348 | } 349 | console.log(result); 350 | } 351 | 352 | /** 353 | * Copies the given grid. 354 | * @param grid: flat array representation of game grid. 355 | * @return copy of given grid. 356 | */ 357 | function copy(grid) { 358 | return grid.slice(); 359 | } 360 | 361 | /** 362 | * Determines whether the given location is within grid bounds. 363 | * @param row: position row. 364 | * @param col: position column. 365 | * @return whether the given location is within grid bounds. 366 | */ 367 | function inBounds(row, col) { 368 | return 0 <= row && row < GRID_SIZE && 0 <= col && col < GRID_SIZE; 369 | } 370 | 371 | /** 372 | * Makes the given move on the grid without inserting new tile. 373 | * @param grid: flat array representation of game grid. 374 | * @param move: object containing move vectors. 375 | * @return whether the move was made successfully. 376 | */ 377 | function make(grid, move) { 378 | var start = move.dir * (GRID_SIZE - 1); 379 | var end = (1 - move.dir) * (GRID_SIZE + 1) - 1; 380 | var inc = 1 - 2 * move.dir; 381 | 382 | var anyMoved = false; 383 | 384 | for (var r = start; r != end; r += inc) { 385 | for (var c = start; c != end; c += inc) { 386 | if (get(grid, r, c)) { 387 | var newr = r + move.drow; 388 | var newc = c + move.dcol; 389 | var oldr = r; 390 | var oldc = c; 391 | 392 | while (inBounds(newr, newc)) { 393 | var target = get(grid, newr, newc); 394 | var tile = get(grid, oldr, oldc); 395 | if (!target) { 396 | set(grid, newr, newc, tile); 397 | set(grid, oldr, oldc, 0); 398 | anyMoved = true; 399 | } 400 | else if (target === tile) { 401 | // negative to prevent additional merging 402 | set(grid, newr, newc, -2 * tile); 403 | set(grid, oldr, oldc, 0); 404 | anyMoved = true; 405 | break; 406 | } 407 | oldr = newr; 408 | oldc = newc; 409 | newr += move.drow; 410 | newc += move.dcol; 411 | } 412 | } 413 | } 414 | } 415 | 416 | if (!anyMoved) { 417 | return false; 418 | } 419 | 420 | var numEmpty = 0; 421 | for (var i = 0; i < grid.length; i++) { 422 | if (grid[i] < 0) { 423 | grid[i] *= -1; 424 | } 425 | else if (!grid[i]) { 426 | numEmpty++; 427 | } 428 | } 429 | 430 | if (numEmpty === 0) { 431 | throw 'No empty squares after making move.'; 432 | } 433 | 434 | return true; 435 | } 436 | 437 | /** 438 | * Computes hash key for the given game grid. 439 | * @param grid: flat array representation of game grid. 440 | * @return hash key for the given game grid. 441 | */ 442 | function getGridKey(grid) { 443 | if (!getGridKey.table1) { 444 | getGridKey.table1 = {}; 445 | getGridKey.table2 = {}; 446 | 447 | for (var i = 0; i < grid.length; i++) { 448 | for (var t = 2; t <= 8192; t *= 2) { 449 | var key = t * grid.length + i; 450 | getGridKey.table1[key] = Math.round(0xffffffff * Math.random()); 451 | getGridKey.table2[key] = Math.round(0xffffffff * Math.random()); 452 | } 453 | } 454 | } 455 | 456 | var value1 = 0; 457 | var value2 = 0; 458 | for (var i = 0; i < grid.length; i++) { 459 | var tile = grid[i]; 460 | if (tile) { 461 | var key = tile * grid.length + i; 462 | value1 ^= getGridKey.table1[key]; 463 | value2 ^= getGridKey.table2[key]; 464 | } 465 | } 466 | 467 | return value1 + '' + value2; 468 | } 469 | 470 | /** 471 | * Constructs current game grid from DOM. 472 | * @return flat array representation of game grid. 473 | */ 474 | function getGrid() { 475 | var tileContainer = document.getElementsByClassName('tile-container')[0]; 476 | var tileList = []; 477 | 478 | for (var i = 0 ; i < tileContainer.children.length; i++) { 479 | var tile = tileContainer.children[i]; 480 | var tileInner = tile.children[0]; 481 | var value = parseInt(tileInner.innerHTML); 482 | 483 | var className = tile.className; 484 | var positionPrefix = 'tile-position-'; 485 | var positionIndex = className.indexOf(positionPrefix) + positionPrefix.length; 486 | var positionStr = className.substring(positionIndex, positionIndex + 3); 487 | var row = parseInt(positionStr[2]) - 1; 488 | var col = parseInt(positionStr[0]) - 1; 489 | 490 | tileList.push({ 491 | value: value, 492 | row: row, 493 | col: col 494 | }); 495 | } 496 | 497 | var grid = new Array(GRID_SIZE * GRID_SIZE); 498 | for (var i = 0; i < grid.length; i++) { 499 | grid[i] = 0; 500 | } 501 | for (var i = 0; i < tileList.length; i++) { 502 | var tile = tileList[i]; 503 | set(grid, tile.row, tile.col, tile.value); 504 | } 505 | 506 | return grid; 507 | } 508 | 509 | /** 510 | * Emulates a keypress for a given move. 511 | * @param move: object containing key information. 512 | */ 513 | function pressKey(move) { 514 | var event = new Event('keydown', { 515 | bubbles: true, 516 | cancelable: true 517 | }); 518 | event.altKey = false; 519 | event.char = ''; 520 | event.charCode = 0; 521 | event.ctrlKey = false; 522 | event.defaultPrevented = false; 523 | event.eventPhase = 3; 524 | event.isTrusted = true; 525 | event.key = move.key; 526 | event.keyCode = move.keyCode; 527 | event.locale = 'en-CA'; 528 | event.location = 0; 529 | event.metaKey = false; 530 | event.repeat = false; 531 | event.shiftKey = false; 532 | event.which = move.keyCode; 533 | 534 | document.body.dispatchEvent(event); 535 | } 536 | 537 | /** 538 | * Determines whether the current game has been lost from the DOM. 539 | * @return whether current game has concluded. 540 | */ 541 | function gameLost() { 542 | var gameMessage = document.getElementsByClassName('game-message')[0]; 543 | return gameMessage.className.indexOf('game-over') >= 0; 544 | } 545 | 546 | /** 547 | * Determines whether the current game has been won from the DOM. 548 | * @return whether current game has concluded. 549 | */ 550 | function gameWon() { 551 | var gameMessage = document.getElementsByClassName('game-message')[0]; 552 | return gameMessage.className.indexOf('game-won') >= 0; 553 | } 554 | 555 | /** 556 | * Starts a new game when the current game has concluded. 557 | */ 558 | function tryAgain() { 559 | var retryButton = document.getElementsByClassName('retry-button')[0]; 560 | retryButton.click(); 561 | } 562 | 563 | /** 564 | * Continues the game when the current game has been won. 565 | */ 566 | function keepPlaying() { 567 | var keepPlayingButton = document.getElementsByClassName('keep-playing-button')[0]; 568 | keepPlayingButton.click(); 569 | } 570 | 571 | /** 572 | * Gets the current score from the DOM. 573 | * @return current score of game. 574 | */ 575 | function getScore() { 576 | var scoreContainer = document.getElementsByClassName('score-container')[0]; 577 | return parseInt(scoreContainer.innerHTML); 578 | } 579 | 580 | /** 581 | * Gets the best score from the DOM. 582 | * @return best score in all games. 583 | */ 584 | function getBestScore() { 585 | var bestContainer = document.getElementsByClassName('best-container')[0]; 586 | return parseInt(bestContainer.innerHTML); 587 | } 588 | 589 | --------------------------------------------------------------------------------