From 6222f547efb17a344ec6d1d9124bb6e0ca057618 Mon Sep 17 00:00:00 2001 From: aswanson-nr Date: Sat, 26 Feb 2022 18:04:15 -0800 Subject: [PATCH] improve heuristics * improve heuristics by considering bad stuff at each move * move game state heuristics to maximum depth * fix lots of bugs --- src/logic.ts | 51 ++++++-- src/lookahead_snake.ts | 121 +++++++++++------ src/utils/board.ts | 20 ++- test/lookahead_snake.test.ts | 244 ++++++++++++++++++++++++++++++++--- test/utils/board.test.ts | 9 ++ 5 files changed, 365 insertions(+), 80 deletions(-) diff --git a/src/logic.ts b/src/logic.ts index c3c0b19..651951f 100644 --- a/src/logic.ts +++ b/src/logic.ts @@ -35,8 +35,32 @@ export function move(state: GameState): MoveResponse { } const getMoveResponse = (location: Coord, state: GameState): MoveResponse => { - const neighbors = getPossibleMoves(state.you.head, state.board); + const width = state.board.width; + const height = state.board.height; + const you = state.you; + const neighbors = [ + { x: you.head.x - 1, y: you.head.y, dir: "left" }, + { x: you.head.x + 1, y: you.head.y, dir: "right" }, + { x: you.head.x, y: you.head.y - 1, dir: "down" }, + { x: you.head.x, y: you.head.y + 1, dir: "up" }, + ] + .map(({ x, y, dir }) => ({ + x: x % width, + y: y % height, + dir, + })) + .map(({ x, y, dir }) => { + if (x < 0) { + x = width - 1; + } + if (y < 0) { + y = height - 1; + } + return { x, y, dir }; + }); + if (location) { + log(neighbors); const [move] = neighbors.filter(isCoordEqual(location)); if (move) { @@ -47,14 +71,19 @@ const getMoveResponse = (location: Coord, state: GameState): MoveResponse => { return { move: "left" }; }; -interface Move extends Coord { - dir: string; -} +//interface Move extends Coord { +//dir: string; +//} + +//const getPossibleMoves = (location: Coord, { height, width }: Board): Move[] => +//[ +//{ x: location.x - 1, y: location.y, dir: "left" }, +//{ x: location.x + 1, y: location.y, dir: "right" }, +//{ x: location.x, y: location.y - 1, dir: "down" }, +//{ x: location.x, y: location.y + 1, dir: "up" }, +//].map(({ x, y, dir }) => { +//if (x < 0) { +//x = +//} -const getPossibleMoves = (location: Coord, { height, width }: Board): Move[] => - [ - { x: location.x - 1, y: location.y, dir: "left" }, - { x: location.x + 1, y: location.y, dir: "right" }, - { x: location.x, y: location.y - 1, dir: "down" }, - { x: location.x, y: location.y + 1, dir: "up" }, - ].map(({ x, y, dir }) => ({ x: x % width, y: y % height, dir })); +//}({ x: x % width, y: y % height, dir })); diff --git a/src/lookahead_snake.ts b/src/lookahead_snake.ts index 6313b9a..c8312bb 100644 --- a/src/lookahead_snake.ts +++ b/src/lookahead_snake.ts @@ -23,6 +23,7 @@ import { import { log } from "./utils/general"; import { createQueue, Queue } from "./utils/queue"; +const coordStr = (c: Coord) => `${c?.x},${c?.y}`; export const voronoi = (gs: GameStateSim): number => { interface Pair { @@ -270,18 +271,9 @@ const nodeHeuristic = ( const stateHeuristic = (gs: GameStateSim): number => { const { you, board, turn, grid } = gs; - const headNode = grid[you.head.y][you.head.x]; const otherSnakes = gs.board.snakes.filter((s) => s.id !== gs.you.id); let total = 0; - const isHead = isCoordEqual(you.head); - const snakesWithMoves: { - snake: Battlesnake; - possibleMoves: Node[]; - }[] = otherSnakes.map((s) => ({ - snake: s, - possibleMoves: getMoves(grid, s.body, gs.isWrapped), - })); if (didWeWinBoys(gs, you)) { //printGrid(gs.grid); @@ -295,55 +287,61 @@ const stateHeuristic = (gs: GameStateSim): number => { return -Infinity; } - total += 10000 / board.snakes.length ?? 1; + total += 10000 / otherSnakes.length ?? 1; const foodPaths = board.food .map((f) => BFS(grid, you.head, f)) .sort((a, b) => a.length - b.length); - const a = 40; // much hungrier in the beginning - const b = 2; - for (let i = 0; i < 3; i++) { + const a = -60; // much hungrier in the beginning + const b = 1; + for (let i = 0; i < foodPaths.length; i++) { const foodPath = foodPaths[i]; if (foodPath) { total += a * Math.atan(you.health - foodPath.length / b); } } - //foodPaths.forEach((foodPath) => { - //// TODO: handle hazards when there isn't food, could also factor in the number of hazard spaces on the path - ////const foodDistanceCost = headNode.hasHazard ? 16 : 1; - //total += a * Math.atan(you.health - foodPath.length / b); - //}); - //if (headNode.hasFood) { - //total += 100; - //} const voronoiScore = voronoi(gs); - log(`voronoi for ${you.head.x},${you.head.y} score:${voronoiScore}`); + //log(`voronoi for ${you.head.x},${you.head.y} score:${voronoiScore}`); if (voronoiScore) { - total += voronoiScore * 10; // should be a hyper parameter + total += voronoiScore * 1; // should be a hyper parameter } + //log(`head:${you.head.x},${you.head.y} total:${total}`); + return total; +}; + +const pathHeuristic = (gs: GameStateSim, move: Coord): number => { + const { you, grid } = gs; + const otherSnakes = gs.board.snakes.filter((s) => s.id !== gs.you.id); + const moveNode = grid[move.y][move.x]; + + let total = 0; + const isMove = isCoordEqual(move); + const snakesWithMoves: { + snake: Battlesnake; + possibleMoves: Node[]; + }[] = otherSnakes.map((s) => ({ + snake: s, + possibleMoves: getMoves(grid, s.body, gs.isWrapped), + })); + for (const pm of snakesWithMoves) { - if (pm.possibleMoves.map(prop("coord")).some(isHead)) { + if (pm.possibleMoves.map(prop("coord")).some(isMove)) { if (pm.snake.length >= you.length) { - log(`possible death move:${you.head.x},${you.head.y}`); - total = -10000; - } - if (pm.snake.length < you.length) { - total = total * 1000; + log(`possible death move:${coordStr(move)}`); + total += -10000000; //change this since it's just a possible move } } } - if (headNode.hasHazard) { - total += -Math.atan(you.health / 16); + if (moveNode.hasHazard) { + total += -Math.atan(you.health / 5); } - if (headNode.hasSnakeTail && hasDuplicates(headNode.snake?.body ?? [])) { + if (moveNode.hasSnakeTail && hasDuplicates(moveNode.snake?.body ?? [])) { total = -Infinity; } - - log(`head:${you.head.x},${you.head.y} total:${total}`); return total; }; @@ -387,18 +385,40 @@ export const alphabeta = ( if (maximizingPlayer) { let value = -Infinity; - for (const pm of getMoves(grid, you.body, isWrapped)) { + const n = getMoves(grid, you.body, isWrapped); + log(`moves at depth:${depth}`); + log(n); + for (const pm of n) { const ns = cloneGameState(gs); - log(`testing move:${pm.coord.x},${pm.coord.y}`); + log(`testing move:${coordStr(pm.coord)} depth:${depth}`); addMove(ns, you, pm.coord); - const moveH = stateHeuristic(ns); + const moveH = pathHeuristic(ns, pm.coord); const min = alphabeta(ns, pm.coord, depth - 1, alpha, beta, false); + //const = moveH + Math.max(value, next.score); + if (moveH < 0) { - value = moveH; + log( + `moveH override for ${coordStr( + pm.coord + )} depth:${depth} move:${coordStr(move)}` + ); + min.score = moveH; } + + log( + `comparing score:${value} move:${coordStr(move)} to score:${ + min.score + } move:${coordStr(min.move)} depth:${depth}` + ); if (min.score > value) { + log( + `taking higher score:${min.score} move:${coordStr( + min.move + )} depth:${depth}` + ); + log(`setting move to parent:${coordStr(pm.coord)}`); value = min.score; move = pm.coord; } @@ -409,6 +429,9 @@ export const alphabeta = ( alpha = Math.max(alpha, value); } //log({ score: value, move }); + log(`return max score:${value} move:${coordStr(move)} depth:${depth}`); + // + // TODO: if all the moves are terrible, it can return it's own head return { score: value, move }; } else { let value = Infinity; @@ -421,11 +444,15 @@ export const alphabeta = ( addMove(ns, enemy, pm.coord); const nextTurn = resolveTurn(ns); - const max = alphabeta(nextTurn, pm.coord, depth - 1, alpha, beta, true); + const max = alphabeta(nextTurn, move, depth - 1, alpha, beta, true); //value = Math.min(value, max.score); if (max.score < value) { + log( + `taking lower score:${max.score} move:${coordStr( + max.move + )} depth:${depth}` + ); value = max.score; - move = pm.coord; } if (value <= alpha) { log(`min prunning`); @@ -446,6 +473,7 @@ export const alphabeta = ( beta = Math.min(beta, value); } + log(`return max score:${value} move:${coordStr(move)} depth:${depth}`); return { score: value, move }; } }; @@ -455,7 +483,7 @@ export const determineMove = (state: GameState, depth: number = 2): Coord => { const isWrapped = state.game.ruleset.name === "wrapped"; const grid = createGrid(board); const ns = cloneGameState({ ...state, pendingMoves: [], isWrapped, grid }); - + log(`starting ${state.game.ruleset.name} game`); //const perms = createGameStatePermutations(ns); //const [bestMove, ...rest] = perms @@ -466,7 +494,14 @@ export const determineMove = (state: GameState, depth: number = 2): Coord => { //}; //}) //.sort((a, b) => b.score - a.score); - + //const moves = getMoves(grid, ns.you.body, isWrapped) + //.map((m) => { + //log(m.coord); + //return m; + //}) + //.map((m) => alphabeta(ns, m.coord, depth, -Infinity, Infinity, true)) + //.sort((a, b) => b.score - a.score); const move = alphabeta(ns, ns.you.head, depth, -Infinity, Infinity, true); - return move.move; + log(move); + return move?.move; }; diff --git a/src/utils/board.ts b/src/utils/board.ts index e84ec3d..cbcfb9b 100644 --- a/src/utils/board.ts +++ b/src/utils/board.ts @@ -1,5 +1,5 @@ import { Battlesnake, Board, Coord, Ruleset } from "../types"; -import { prop } from "./general"; +import { log, prop } from "./general"; import { createQueue } from "./queue"; export interface Node { @@ -138,10 +138,20 @@ export const adjustForWrapped = ( height: number, width: number ): Coord[] => - coords.map(({ x, y }) => ({ - x: x % width, - y: y % height, - })); + coords + .map(({ x, y }) => ({ + x: x % width, + y: y % height, + })) + .map(({ x, y }) => { + if (x < 0) { + x = width - 1; + } + if (y < 0) { + y = height - 1; + } + return { x, y }; + }); export const isInArray = (coords: Coord[]) => (l: Coord): boolean => coords.some(isCoordEqual(l)); diff --git a/test/lookahead_snake.test.ts b/test/lookahead_snake.test.ts index 2174b60..c7ceb9d 100644 --- a/test/lookahead_snake.test.ts +++ b/test/lookahead_snake.test.ts @@ -533,6 +533,208 @@ describe("alphabeta", () => { expect(move).not.toEqual({ x: 6, y: 2 }); }); + it("doesn't go for the head to head and lose", () => { + // f _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ e e e + // _ _ _ _ _ _ f _ _ _ e + // _ _ _ _ _ _ _ _ _ _ e + // s s s s s h _ f _ _ e + // s f f _ _ _ h e e e e + // _ _ _ f _ _ _ _ _ f f + // f _ _ _ _ _ _ f _ _ f + // f _ _ _ _ _ f _ _ _ f + // _ _ _ _ _ _ _ _ _ f _ + // _ _ _ _ f _ f _ _ _ f + // h = my head, k = enemy head + + const me = createSnake( + [ + { x: 5, y: 6 }, + { x: 4, y: 6 }, + { x: 3, y: 6 }, + { x: 2, y: 6 }, + { x: 1, y: 6 }, + { x: 0, y: 6 }, + { x: 0, y: 5 }, + ], + { health: 77 } + ); + + const snake2 = createSnake( + [ + { x: 6, y: 5 }, + { x: 7, y: 5 }, + { x: 8, y: 5 }, + { x: 9, y: 5 }, + { x: 10, y: 5 }, + { x: 10, y: 6 }, + { x: 10, y: 7 }, + { x: 10, y: 8 }, + { x: 10, y: 9 }, + { x: 9, y: 9 }, + { x: 8, y: 9 }, + ], + { health: 54 } + ); + + const food = [ + { x: 0, y: 10 }, + { x: 6, y: 8 }, + { x: 7, y: 6 }, + { x: 0, y: 2 }, + { x: 0, y: 3 }, + { x: 1, y: 5 }, + { x: 2, y: 5 }, + { x: 3, y: 4 }, + { x: 4, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 2 }, + { x: 7, y: 3 }, + { x: 9, y: 4 }, + { x: 10, y: 4 }, + { x: 10, y: 3 }, + { x: 10, y: 2 }, + { x: 9, y: 1 }, + { x: 10, y: 0 }, + ]; + const gameState = createGameState( + createBoard(11, food, [me, snake2]), + me, + 109, + "wrapped" + ); + const move = determineMove(gameState); + expect(move).not.toEqual({ x: 5, y: 5 }); + }); + + it("sees wrapped moves", () => { + // ◦◦◦◦■■■⌀◦◦◦ + // ■■■■■◦◦◦◦◦■ + // ◦◦◦◦◦◦◦◦◦◦■ + // ◦⌀⌀⌀◦◦◦◦◦◦■ + // ◦⌀◦⌀⌀◦◦◦⚕◦■ + // ⚕⌀◦⚕⌀◦◦◦◦◦■ + // ⌀⌀◦⚕⌀⌀◦◦◦◦■ + // ⌀⚕◦◦⌀⌀◦◦◦◦■ + // ⌀⌀⌀⌀◦◦◦◦◦■■ + // ⌀⌀⚕⌀⌀⌀⌀⌀⚕■◦ + // ◦⚕■■■■■⌀◦◦◦ + // _ _ _ _ s s s e _ _ _ + // s s s s s _ _ _ _ _ s + // _ _ _ _ _ _ _ _ _ _ s + // _ e e e _ _ _ _ _ _ s + // _ e _ e e _ _ _ f _ s + // f e _ f e _ _ _ _ _ s + // e e _ f e e _ _ _ _ s + // e f _ _ e e _ _ _ _ s + // e e e e _ _ _ _ _ s s + // e e f e e e e e f s _ + // _ f h s s s s e _ _ _ + // h = my head, x = hazard + + const me = createSnake( + [ + { x: 2, y: 0 }, + { x: 3, y: 0 }, + { x: 4, y: 0 }, + { x: 5, y: 0 }, + { x: 6, y: 0 }, + { x: 6, y: 10 }, + { x: 5, y: 10 }, + { x: 4, y: 10 }, + { x: 4, y: 9 }, + { x: 3, y: 9 }, + { x: 2, y: 9 }, + { x: 1, y: 9 }, + { x: 0, y: 9 }, + { x: 10, y: 9 }, + { x: 10, y: 8 }, + { x: 10, y: 7 }, + { x: 10, y: 6 }, + { x: 10, y: 5 }, + { x: 10, y: 4 }, + { x: 10, y: 3 }, + { x: 10, y: 2 }, + { x: 9, y: 2 }, + { x: 9, y: 1 }, + ], + { health: 97 } + ); + + // _ _ _ _ s s s k _ _ _ + // s s s s s _ _ _ _ _ s + // _ _ _ _ _ _ _ _ _ _ s + // _ e e e _ _ _ _ _ _ s + // _ e _ e e _ _ _ f _ s + // f e _ f e _ _ _ _ _ s + // e e _ f e e _ _ _ _ s + // e f _ _ e e _ _ _ _ s + // e e e e _ _ _ _ _ s s + // e e f e e e e e f s _ + // _ f h s s s s e _ _ _ + // h = my head, x = hazard + const snake2 = createSnake( + [ + { x: 7, y: 10 }, + { x: 7, y: 0 }, + { x: 7, y: 1 }, + { x: 6, y: 1 }, + { x: 5, y: 1 }, + { x: 4, y: 1 }, + { x: 3, y: 1 }, + { x: 3, y: 2 }, + { x: 2, y: 2 }, + { x: 1, y: 2 }, + { x: 1, y: 1 }, + { x: 0, y: 1 }, + { x: 0, y: 2 }, + { x: 0, y: 3 }, + { x: 0, y: 4 }, + { x: 1, y: 4 }, + { x: 1, y: 5 }, + { x: 1, y: 6 }, + { x: 1, y: 7 }, + { x: 2, y: 7 }, + { x: 3, y: 7 }, + { x: 3, y: 6 }, + { x: 4, y: 6 }, + { x: 4, y: 5 }, + { x: 4, y: 4 }, + { x: 4, y: 3 }, + { x: 5, y: 3 }, + { x: 5, y: 4 }, + ], + { health: 98 } + ); + + // _ _ _ _ s s s k _ _ _ + // s s s s s _ _ _ _ _ s + // _ _ _ _ _ _ _ _ _ _ s + // _ e e e _ _ _ _ _ _ s + // _ e _ e e _ _ _ f _ s + // f e _ f e _ _ _ _ _ s + // e e _ f e e _ _ _ _ s + // e f _ _ e e _ _ _ _ s + // e e e e _ _ _ _ _ s s + // e e f e e e e e f s _ + // _ f h s s s s e _ _ _ + // h = my head, x = hazard + const food = [ + { x: 1, y: 0 }, + { x: 2, y: 1 }, + { x: 8, y: 6 }, + { x: 8, y: 1 }, + ]; + const gameState = createGameState( + createBoard(11, food, [me, snake2]), + me, + 109, + "wrapped" + ); + const move = determineMove(gameState); + expect(move).not.toEqual({ x: 2, y: 1 }); + }); + it("chooses life over zoning", () => { // _ _ _ _ _ _ f _ _ _ _ // _ _ _ _ _ _ _ _ f _ _ @@ -549,10 +751,10 @@ describe("alphabeta", () => { const me = createSnake( [ - { x: 4, y: 6 }, - { x: 5, y: 6 }, - { x: 6, y: 6 }, - { x: 6, y: 7 }, + { x: 3, y: 4 }, + { x: 4, y: 4 }, + { x: 5, y: 4 }, + { x: 5, y: 3 }, ], { health: 3 } ); @@ -560,29 +762,29 @@ describe("alphabeta", () => { [ { x: 4, y: 5 }, { x: 5, y: 5 }, - { x: 5, y: 4 }, - { x: 6, y: 4 }, - { x: 7, y: 4 }, - { x: 8, y: 4 }, - { x: 9, y: 4 }, - { x: 10, y: 4 }, + { x: 5, y: 6 }, + { x: 6, y: 6 }, + { x: 7, y: 6 }, + { x: 8, y: 6 }, + { x: 9, y: 6 }, + { x: 10, y: 6 }, { x: 10, y: 5 }, ], { health: 98 } ); const food = [ - { x: 0, y: 8 }, - { x: 1, y: 8 }, + { x: 0, y: 2 }, { x: 1, y: 2 }, - { x: 3, y: 7 }, - { x: 3, y: 1 }, - { x: 4, y: 3 }, - { x: 6, y: 10 }, - { x: 6, y: 2 }, - { x: 8, y: 9 }, - { x: 8, y: 1 }, - { x: 10, y: 1 }, + { x: 1, y: 8 }, + { x: 3, y: 3 }, + { x: 3, y: 9 }, + { x: 4, y: 7 }, + { x: 6, y: 0 }, + { x: 6, y: 8 }, { x: 8, y: 1 }, + { x: 8, y: 9 }, + { x: 9, y: 9 }, + { x: 10, y: 9 }, ]; const gameState = createGameState( createBoard(11, food, [me, snake2]), @@ -590,7 +792,7 @@ describe("alphabeta", () => { 109 ); const move = determineMove(gameState); - expect(move).toEqual({ x: 4, y: 7 }); + expect(move).not.toEqual({ x: 3, y: 5 }); }); it("does not choose death by hazard", () => { diff --git a/test/utils/board.test.ts b/test/utils/board.test.ts index 301dd4d..d42619e 100644 --- a/test/utils/board.test.ts +++ b/test/utils/board.test.ts @@ -49,6 +49,15 @@ describe("utils/board", () => { }); describe("getNeighbors", () => { + it("returns wrapped values", () => { + const grid = createGrid(createBoard(3)); + const neigbors = getNeighbors(grid, true)({ x: 0, y: 0 }); + expect(neigbors).toHaveLength(4); + expect(neigbors[0].coord).toEqual({ x: 0, y: 1 }); + expect(neigbors[1].coord).toEqual({ x: 1, y: 0 }); + expect(neigbors[2].coord).toEqual({ x: 0, y: 2 }); + expect(neigbors[3].coord).toEqual({ x: 2, y: 0 }); + }); it("returns values on grid", () => { const grid = createGrid(createBoard(3)); const neigbors = getNeighbors(grid)({ x: 0, y: 0 });