From c4c60cc028093096c67f8fa93f9b3ab9a2acc3b0 Mon Sep 17 00:00:00 2001 From: aswanson-nr Date: Sat, 19 Feb 2022 22:56:39 -0800 Subject: [PATCH 1/2] smarter better faster stronger * move to heuristic instead of a-star for selecting move * use voronoi method to factor in area control * add board utilities, including breadth first search * more testsssss --- .gitignore | 2 + .replit | 2 - CODEOWNERS | 1 - package.json | 13 +- src/a-star.ts | 2 +- src/heuristic_snake.ts | 121 ++++++++++++++++ src/logic.ts | 153 +++----------------- src/utils/board.ts | 182 ++++++++++++++++++++++++ src/utils/game-sim.ts | 38 +++++ src/utils/general.ts | 1 + src/utils/queue.ts | 25 ++++ test/heuristic_snake.test.ts | 268 +++++++++++++++++++++++++++++++++++ test/logic.test.ts | 76 ---------- test/utils.ts | 51 +++++++ test/utils/board.test.ts | 95 +++++++++++++ test/utils/queue.test.ts | 80 +++++++++++ yarn.lock | 10 ++ 17 files changed, 902 insertions(+), 218 deletions(-) delete mode 100644 .replit delete mode 100644 CODEOWNERS create mode 100644 src/heuristic_snake.ts create mode 100644 src/utils/board.ts create mode 100644 src/utils/game-sim.ts create mode 100644 src/utils/general.ts create mode 100644 src/utils/queue.ts create mode 100644 test/heuristic_snake.test.ts delete mode 100644 test/logic.test.ts create mode 100644 test/utils.ts create mode 100644 test/utils/board.test.ts create mode 100644 test/utils/queue.test.ts diff --git a/.gitignore b/.gitignore index f6ef6fa..7455a20 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules build +newrelic_agent.log +yarn-error.log diff --git a/.replit b/.replit deleted file mode 100644 index b84e11d..0000000 --- a/.replit +++ /dev/null @@ -1,2 +0,0 @@ -language = "typescript" -run = "npm start" diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 0a45b56..0000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @chris-bsnake @aurorawalker @bvanvugt @originalwebgurl diff --git a/package.json b/package.json index 7eb98e5..a8b5d53 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "jormungandr", - "version": "1.0.0", - "description": "A simple Battlesnake written in TypeScript", + "name": "shai", + "version": "2.0.0", + "description": "A maybe more than simple Battlesnake written in TypeScript", "scripts": { "build": "tsc", "prestart": "npm run build", @@ -10,21 +10,22 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/BattlesnakeOfficial/starter-snake-typescript.git" + "url": "git+https://github.com/nosnaws/shai-hulud.git" }, "keywords": [], - "author": "BattlesnakeOfficial", + "author": "nosnaws", "license": "MIT", - "homepage": "https://github.com/BattlesnakeOfficial/starter-snake-typescript#readme", "dependencies": { "@newrelic/winston-enricher": "^2.1.0", "@types/express": "^4.17.13", "@types/newrelic": "^7.0.3", "@types/node": "^16.3.3", + "@types/uuid": "^8.3.4", "express": "^4.17.1", "newrelic": "^8.7.1", "pm2": "^5.1.2", "typescript": "^4.3.5", + "uuid": "^8.3.2", "winston": "^3.6.0", "winston-to-newrelic-logs": "^1.0.11" }, diff --git a/src/a-star.ts b/src/a-star.ts index 4c6cf04..1e5489c 100644 --- a/src/a-star.ts +++ b/src/a-star.ts @@ -230,7 +230,7 @@ export const snakeLength = ( return snake ? snake.length : undefined; }; -export const prop = (key: K) => (obj: T) => obj[key]; +const prop = (key: K) => (obj: T) => obj[key]; export const areCoordsEqual = (a: Coord, b: Coord) => a.x === b.x && a.y === b.y; diff --git a/src/heuristic_snake.ts b/src/heuristic_snake.ts new file mode 100644 index 0000000..6e7c87e --- /dev/null +++ b/src/heuristic_snake.ts @@ -0,0 +1,121 @@ +import { Coord, GameState } from "./types"; +import { prop } from "./utils/general"; +import { + BFS, + getAllPossibleMoves, + Grid, + Node, + isCoordEqual, + createGrid, + getNeighbors, +} from "./utils/board"; + +export const voronoriCounts = ( + grid: Grid, + roots: Coord[] +): { root: Coord; score: number }[] => { + const height = grid.length; + const width = grid[0].length; + const scores = roots.map((root) => ({ root, score: 0 })); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const node = grid[y][x]; + if (node.hasSnake) { + continue; + } + + const pathsAsc = roots + .map((r) => BFS(grid, r, node.coord)) + .filter((p) => p.length > 0) + .sort((a, b) => a.length - b.length); + const [p1, p2] = pathsAsc; + + if (!p1 || (p2 && p1.length === p2.length)) { + // space unreachable or is unowned + continue; + } + + const isOwner = isCoordEqual(p1[0].coord); + const ownerScore = scores.find((s) => isOwner(s.root)); + if (ownerScore) { + ownerScore.score += 1; + } + } + } + + return scores; +}; + +// TODO: should I subtract the end of each snake when evaluating moves? +// * i think so +// * unless they could eat a food +const nodeHeuristic = ( + grid: Grid, + { board, you, game, turn }: GameState, + node: Node +): number => { + const isWrapped = game.ruleset.name === "wrapped"; + let total = 0; + const snakes = board.snakes; + const food = board.food; + const isCurrent = isCoordEqual(node.coord); + const enemySnakes = snakes.filter((s) => s.id !== you.id); + const smallerSnakes = enemySnakes.filter((s) => s.length < you.length); + const largerSnakes = enemySnakes.filter((s) => s.length >= you.length); + const isPossibleKillMove = + getAllPossibleMoves(grid, smallerSnakes.map(prop("head")), isWrapped) + .map(prop("coord")) + .filter(isCurrent).length > 0; + + const isPossibleDeathMove = + getAllPossibleMoves(grid, largerSnakes.map(prop("head")), isWrapped) + .map(prop("coord")) + .filter(isCurrent).length > 0; + + if (isPossibleKillMove) { + total += 10; + } + + if (isPossibleDeathMove) { + total += -1000; + } + + if (node.hasHazard) { + total -= 16 / you.health; + } + // TODO: eval possible snake tail, maybe + + // Factor in distance to food + const orderedFood = food.map((f) => BFS(grid, node.coord, f)); + const a = 50; + const b = turn < 50 ? 1 : 5; // try to play hungry for the first 50 turns + orderedFood.forEach((foodPath) => { + total += a * Math.atan((you.health - foodPath.length) / b); + }); + + const voronoiScores = voronoriCounts(grid, [ + node.coord, + ...enemySnakes.map(prop("head")), + ]); + const voronoiScore = voronoiScores.find((s) => isCurrent(s.root)); + + if (voronoiScore) { + total += voronoiScore.score * 1; + } + + return total; +}; + +export const determineMove = (state: GameState): Coord => { + const board = state.board; + const you = state.you; + const isWrapped = state.game.ruleset.name === "wrapped"; + const grid = createGrid(board); + const possibleMoves = getNeighbors(grid, isWrapped)(you.head); + + const [bestMove, ...rest] = possibleMoves + .map((move) => ({ move, score: nodeHeuristic(grid, state, move) })) + .sort((a, b) => b.score - a.score); + return bestMove?.move.coord; +}; diff --git a/src/logic.ts b/src/logic.ts index 8a35cb5..deea5fc 100644 --- a/src/logic.ts +++ b/src/logic.ts @@ -1,13 +1,8 @@ -import { InfoResponse, GameState, MoveResponse, Game, Coord } from "./types"; -import { - aStar, - manhattanDistance, - areCoordsEqual, - prop, - createSpace, - Space, -} from "./a-star"; +import { InfoResponse, GameState, MoveResponse, Coord, Board } from "./types"; import getLogger from "./logger"; +import { isCoordEqual } from "./utils/board"; + +import { determineMove } from "./heuristic_snake"; const logger = getLogger(); export function info(): InfoResponse { @@ -31,139 +26,33 @@ export function end(gameState: GameState): void { export function move(state: GameState): MoveResponse { logger.info(`${state.game.id} MOVE`); - const moveRes = calculateMove(state); + const moveCoord = determineMove(state); + const moveRes = getMoveResponse(moveCoord, state); logger.info(`${state.game.id} MOVE ${state.turn}: ${moveRes.move}`); return moveRes; } +const getMoveResponse = (location: Coord, state: GameState): MoveResponse => { + const neighbors = getPossibleMoves(state.you.head, state.board); + if (location) { + const [move] = neighbors.filter(isCoordEqual(location)); + + if (move) { + return { move: move.dir }; + } + } + logger.info("no move, going left"); + return { move: "left" }; +}; + interface Move extends Coord { dir: string; } -const getPossibleMoves = ( - location: Coord, - { board, game }: GameState -): Move[] => +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((m) => { - const gameMode = game.ruleset.name; - const width = board.width; - const height = board.height; - - if (gameMode === "wrapped") { - return { x: m.x % width, y: m.y % height, dir: m.dir }; - } - return m; - }) - .filter( - (m) => m.x < board.width && m.y < board.height && m.x >= 0 && m.y >= 0 - ); - -const getMove = (move: Coord, state: GameState) => { - if (move) { - const possibleMoves = getPossibleMoves(state.you.head, state); - const possibleMove = possibleMoves.find( - (m) => m.x === move.x && m.y === move.y - ); - - if (possibleMove) { - return possibleMove.dir; - } - } - logger.info(`selected move not possible, going left`); - - return "left"; -}; - -const shuffle = (array: Array): Array => - array.sort(() => 0.5 - Math.random()); - -const getPossibleSpaces = (coord: Coord, state: GameState) => - getPossibleMoves(coord, state).map((m) => createSpace(m, state)); - -const getRandomSafeMove = (coord: Coord, state: GameState): Space[] => - shuffle( - getPossibleSpaces(coord, state).filter( - (s) => !s.hasSnake && !s.isPossibleLargerSnakeHead - ) - ); - -const getRandomDesperateMove = (coord: Coord, state: GameState): Space[] => - shuffle(getPossibleSpaces(coord, state).filter((s) => !s.hasSnake)); - -const calculateMove = (state: GameState): MoveResponse => { - const head = state.you.head; - const length = state.you.length; - const health = state.you.health; - const muchSmallerSnakes = state.board.snakes - .filter((s) => !areCoordsEqual(s.head, head)) - .filter((s) => s.length < length + 2); - - const isKillingTime = false; - // length > 10 && muchSmallerSnakes.length > 0 && health > 50; - - const addDistance = (f: Coord) => ({ - x: f.x, - y: f.y, - distance: manhattanDistance(head, f), - }); - const distanceSort = (a: { distance: number }, b: { distance: number }) => - a.distance - b.distance; - - const foods = state.board.food.map(addDistance).sort(distanceSort); - const smallerSnakeHeads = muchSmallerSnakes - .map(prop("head")) - .flatMap((h) => getPossibleMoves(h, state)) - .filter((g) => !areCoordsEqual(g, head)) - .map(addDistance) - .sort(distanceSort); - - let goals = foods; - if (isKillingTime) { - logger.info(`entering aggressive mode`); - goals = [...smallerSnakeHeads, ...goals]; - } - - const trace = (args: any) => { - console.dir(args); - return args; - }; - const possibleSafeMovesForSpace = (space: Space, state: GameState) => - getPossibleMoves(space.coords, state) - .map((m) => createSpace(m, state)) - .filter((pm) => !pm.hasSnake && !pm.isPossibleLargerSnakeHead); - - const [bestMove]: Space[] = goals - .map((g) => aStar(state, g)) - .map(([m]) => m) - .filter((m) => !areCoordsEqual(m.coords, head)) // remove failed paths - .filter((m) => !m.hasSnake) - .filter((m) => !m.isPossibleLargerSnakeHead) - .filter( - (m) => possibleSafeMovesForSpace(m, state).length > 0 // ensure we won't get stuck ...less - ); - - if (bestMove) { - return { move: getMove(bestMove.coords, state) }; - } - - logger.info(`no usable moves from pathfinding`); - - const [randomMove] = getRandomSafeMove(head, state).filter( - (m) => possibleSafeMovesForSpace(m, state).length > 0 - ); - if (randomMove) { - logger.info(`randomly selected safe move`); - return { move: getMove(randomMove.coords, state) }; - } - - const [desperateMove] = getRandomDesperateMove(head, state); - logger.info(`randomly selected desperate move`); - - return { move: getMove(desperateMove?.coords, state) }; -}; + ].map(({ x, y, dir }) => ({ x: x % width, y: y % height, dir })); diff --git a/src/utils/board.ts b/src/utils/board.ts new file mode 100644 index 0000000..a209b51 --- /dev/null +++ b/src/utils/board.ts @@ -0,0 +1,182 @@ +import { Board, Coord, Ruleset } from "../types"; +import { prop } from "./general"; +import { createQueue } from "./queue"; + +export interface Node { + coord: Coord; + hasFood: boolean; + hasSnake: boolean; + hasHazard: boolean; +} + +export type Grid = Node[][]; + +export const createGrid = ({ + snakes, + food, + hazards, + height, + width, +}: Board): Grid => { + const isFood = isInArray(food); + const isSnake = isInArray(snakes.flatMap(prop("body"))); + const isHazard = isInArray(hazards); + + const grid: Grid = []; + for (let i = 0; i < height; i++) { + grid[i] = []; + for (let j = 0; j < width; j++) { + const coord = { x: j, y: i }; + grid[i][j] = { + coord, + hasFood: isFood(coord), + hasSnake: isSnake(coord), + hasHazard: isHazard(coord), + }; + } + } + + return grid; +}; + +export const BFS = ( + grid: Grid, + root: Coord, + goal: Coord, + isWrapped = false +): Node[] => { + const getEdges = getNeighbors(grid, isWrapped); + const rootNode = grid[root.y][root.x]; + const queue = createQueue([rootNode]); + const visited: { [key: string]: Node } = {}; + const visitedSet = new Set([nodeId(rootNode)]); + + while (queue.size() > 0) { + const current = queue.dequeue(); + if (isCoordEqual(current.coord)(goal)) { + return getPath(current, visited); + } + + //for (const edgeNode of getEdges(current.coord)) { + //if (Object.values(visited).indexOf(edgeNode) === -1) { + //visited[nodeId(edgeNode)] = current; + //queue.enqueue(edgeNode); + //} + //} + const edgeNodes = getEdges(current.coord); + const len = edgeNodes.length; + for (let i = 0; i < len; i++) { + const edgeNode = edgeNodes[i]; + const edgeId = nodeId(edgeNode); + if (!visitedSet.has(edgeId)) { + visited[edgeId] = current; + visitedSet.add(edgeId); + queue.enqueue(edgeNode); + } + } + } + return []; +}; + +const getPath = (current: Node, visited: { [key: string]: Node }): Node[] => { + const path = []; + let node = current; + while (node !== undefined) { + path.push(node); + node = visited[nodeId(node)]; + } + + return path.reverse(); +}; + +export const getAllPossibleMoves = ( + grid: Grid, + roots: Coord[], + isWrapped: boolean +): Node[] => roots.flatMap(getNeighbors(grid, isWrapped)); + +export const getNeighbors = (grid: Grid, isWrapped: boolean = false) => ({ + x, + y, +}: Coord) => { + let neighbors = [ + { x, y: y + 1 }, // up + { x: x + 1, y }, // right + { x, y: y - 1 }, // down + { x: x - 1, y }, // left + ]; + + if (isWrapped) { + neighbors = neighbors.map(({ x, y }) => ({ + x: x % grid[0].length, + y: y % grid.length, + })); + } + + return neighbors + .filter(isCoordInBounds(grid)) + .map(({ x, y }) => grid[y][x]) + .filter((n) => !n.hasSnake); +}; + +export const getNeighborsWrapped = (grid: Grid) => ({ x, y }: Coord) => { + const neighbors = [ + { x, y: y + 1 }, // up + { x: x + 1, y }, // right + { x, y: y - 1 }, // down + { x: x - 1, y }, // left + ] + .map(({ x, y }) => ({ x: x % grid[0].length, y: y % grid.length })) + .filter(isCoordInBounds(grid)) + .map(({ x, y }) => grid[y][x]) + .filter((n) => !n.hasSnake); + + return neighbors; +}; + +const isInArray = (food: Coord[]) => (l: Coord): boolean => + food.some(isCoordEqual(l)); + +export const isCoordEqual = (a: Coord) => (b: Coord) => + a.x === b.x && a.y === b.y; + +const isCoordInBounds = (grid: Grid) => (b: Coord) => + b.y >= 0 && b.y < grid.length && b.x >= 0 && b.x < grid[0].length; + +const nodeId = (node: Node): string => `${node.coord.x}${node.coord.y}`; + +const printGrid = ( + grid: Grid, + root: Coord, + current: Coord, + goal: Coord, + visited: Node[] +) => { + const nodeChar = " . "; + const visitedChar = " v "; + const rootChar = " r "; + const goalChar = " g "; + const currentChar = " c "; + + const getCharForNode = (node: Node) => { + const isEqual = isCoordEqual(node.coord); + if (isEqual(current)) { + return currentChar; + } + if (isEqual(root)) { + return rootChar; + } + if (isEqual(goal)) { + return goalChar; + } + if (visited.indexOf(node) !== -1) { + return visitedChar; + } + + return nodeChar; + }; + + const rowStrings = grid.map((row) => row.map(getCharForNode).join("|")); + + console.dir(rowStrings); +}; diff --git a/src/utils/game-sim.ts b/src/utils/game-sim.ts new file mode 100644 index 0000000..cdb0b50 --- /dev/null +++ b/src/utils/game-sim.ts @@ -0,0 +1,38 @@ +// holy fuck here we go +// +//import { v4 as uuid } from "uuid"; +//import { Battlesnake, Board, Coord, GameState } from "../types"; + +//const initializeSim = () => {}; + +//const addSnake = (state: GameState, snake: Battlesnake): GameState => { +//return {}; +//}; + +//const addBoard = ( +//size: number, +//food: Coord[] = [], +//snakes: Battlesnake[] = [] +//): Board => { +//return { +//height: size, +//width: size, +//food: food, +//snakes, +//hazards: [], +//}; +//}; + +//const defaultSnake = (body: Coord[]): Battlesnake => { +//return { +//id: uuid(), +//name: "test", +//health: 100, +//body, +//latency: "0", +//head: body[0], +//length: body.length, +//shout: "", +//squad: "", +//}; +//}; diff --git a/src/utils/general.ts b/src/utils/general.ts new file mode 100644 index 0000000..cfd8469 --- /dev/null +++ b/src/utils/general.ts @@ -0,0 +1 @@ +export const prop = (key: K) => (obj: T) => obj[key]; diff --git a/src/utils/queue.ts b/src/utils/queue.ts new file mode 100644 index 0000000..ca67fd1 --- /dev/null +++ b/src/utils/queue.ts @@ -0,0 +1,25 @@ +interface Queue { + contents: () => T[]; + size: () => number; + enqueue: (item: T) => number; + dequeue: () => T; +} + +export const createQueue = (initial: T[] = []): Queue => { + let queue = [...initial]; + + const enqueue = (item: T): number => queue.push(item); + + const dequeue = (): T => { + const [head, ...rest] = queue; + queue = rest; + return head; + }; + + return { + enqueue, + dequeue, + contents: () => [...queue], + size: () => queue.length, + }; +}; diff --git a/test/heuristic_snake.test.ts b/test/heuristic_snake.test.ts new file mode 100644 index 0000000..9a1df7c --- /dev/null +++ b/test/heuristic_snake.test.ts @@ -0,0 +1,268 @@ +import { determineMove, voronoriCounts } from "../src/heuristic_snake"; +import { createBoard, createGameState, createSnake } from "./utils"; +import { createGrid } from "../src/utils/board"; + +describe("alphabeta", () => { + describe("voronoriCounts", () => { + it("returns count for 1 snake", () => { + const snake1 = createSnake([{ x: 0, y: 0 }]); + const grid = createGrid(createBoard(3, [], [snake1])); + const [vCounts] = voronoriCounts(grid, [{ x: 0, y: 0 }]); + expect(vCounts.score).toBe(8); + }); + + it("returns counts for 2 snakes", () => { + const snake1 = createSnake([ + { x: 3, y: 3 }, + { x: 3, y: 4 }, + ]); + const snake2 = createSnake([ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + ]); + const grid = createGrid(createBoard(5, [], [snake1, snake2])); + const [s1VCount, s2VCount] = voronoriCounts(grid, [ + snake1.head, + snake2.head, + ]); + expect(s1VCount.score).toBe(13); + expect(s2VCount.score).toBe(3); + }); + }); + + describe("determineMove", () => { + // TODO: These types of move requests are taking a very long way to process + // need to figure out why, because these requests should not be increasing the pathfinding by so much + it("returns move in under 100ms, 2 food", () => { + const snake = createSnake([ + { x: 9, y: 10 }, + { x: 9, y: 9 }, + { x: 9, y: 8 }, + { x: 9, y: 7 }, + { x: 8, y: 7 }, + { x: 7, y: 7 }, + ]); + const gameState = createGameState( + createBoard( + 11, + [ + { x: 1, y: 10 }, + { x: 2, y: 10 }, + ], + [snake] + ), + snake + ); + + const time = Date.now(); + const move = determineMove(gameState); + expect(Date.now() - time).toBeLessThan(100); + }); + it("returns move in under 100ms, 1 food", () => { + const snake = createSnake([ + { x: 9, y: 10 }, + { x: 9, y: 9 }, + { x: 9, y: 8 }, + { x: 9, y: 7 }, + { x: 8, y: 7 }, + { x: 7, y: 7 }, + ]); + const gameState = createGameState( + createBoard(11, [{ x: 1, y: 10 }], [snake]), + snake + ); + + const time = Date.now(); + const move = determineMove(gameState); + expect(Date.now() - time).toBeLessThan(100); + }); + it("returns move in under 100ms, 2 food - 2 snakes", () => { + const snake1 = createSnake([ + { x: 9, y: 10 }, + { x: 9, y: 9 }, + { x: 9, y: 8 }, + { x: 9, y: 7 }, + { x: 8, y: 7 }, + { x: 7, y: 7 }, + ]); + const snake2 = createSnake([ + { x: 0, y: 10 }, + { x: 0, y: 9 }, + { x: 0, y: 8 }, + { x: 0, y: 7 }, + { x: 0, y: 7 }, + { x: 0, y: 7 }, + ]); + + const gameState = createGameState( + createBoard( + 11, + [ + { x: 1, y: 10 }, + { x: 2, y: 10 }, + ], + [snake1, snake2] + ), + snake1 + ); + + const time = Date.now(); + const move = determineMove(gameState); + expect(Date.now() - time).toBeLessThan(100); + }); + + it("doesn't get get itself stuck", () => { + const snake = createSnake([ + { x: 3, y: 0 }, // _ _ _ _ _ + { x: 3, y: 1 }, // _ _ _ _ _ + { x: 3, y: 2 }, // _ _ _ _ _ + { x: 2, y: 2 }, // _ s s s _ + { x: 1, y: 2 }, // _ s _ s _ + { x: 1, y: 1 }, // _ s _ h _ + { x: 1, y: 0 }, + ]); + const gameState = createGameState( + createBoard(5, [{ x: 0, y: 4 }], [snake]), + snake + ); + + const move = determineMove(gameState); + expect(move).toEqual({ x: 4, y: 0 }); + }); + + it("doesn't get get itself stuck", () => { + const snake = createSnake([ + { x: 0, y: 4 }, // _ _ _ _ _ _ _ _ _ _ _ + { x: 1, y: 4 }, // _ _ _ _ f _ _ _ _ _ _ + { x: 2, y: 4 }, // _ _ _ _ _ _ _ _ _ s s + { x: 2, y: 3 }, // _ _ _ _ _ _ _ _ _ _ s + { x: 2, y: 2 }, // _ _ _ _ _ _ _ _ _ _ s + { x: 2, y: 1 }, // _ _ _ _ _ _ _ _ _ _ s + { x: 2, y: 0 }, // h s s _ _ _ _ _ _ _ s + { x: 3, y: 0 }, // _ _ s _ _ _ _ _ _ _ s + { x: 4, y: 0 }, // f _ s _ s s s s s s s + { x: 4, y: 1 }, // _ _ s _ s _ _ _ f _ _ + { x: 4, y: 2 }, // _ _ s s s _ _ _ _ _ _ + { x: 5, y: 2 }, + { x: 6, y: 2 }, + { x: 7, y: 2 }, + { x: 8, y: 2 }, + { x: 9, y: 2 }, + { x: 10, y: 2 }, + { x: 10, y: 3 }, + { x: 10, y: 4 }, + { x: 10, y: 5 }, + { x: 10, y: 6 }, + { x: 10, y: 7 }, + { x: 10, y: 8 }, + { x: 9, y: 8 }, + ]); + const gameState = createGameState( + createBoard( + 11, + [ + { x: 0, y: 2 }, + { x: 8, y: 1 }, + { x: 4, y: 9 }, + ], + [snake] + ), + snake + ); + + const move = determineMove(gameState); + expect(move).toEqual({ x: 0, y: 5 }); + }); + + it("doesn't decide to die", () => { + // _ _ e _ _ _ _ _ _ _ _ + // _ _ e _ _ _ _ _ _ _ _ + // _ _ e _ _ _ _ _ _ _ _ + // _ _ e _ _ _ _ _ _ _ _ + // _ _ k s s s _ _ _ _ _ + // _ _ _ h _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ f _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ f _ _ _ _ _ _ + // h = my head, k = enemy head + + const me = createSnake( + [ + { x: 3, y: 5 }, + { x: 3, y: 6 }, + { x: 4, y: 6 }, + { x: 5, y: 6 }, + ], + { health: 84 } + ); + const other = createSnake( + [ + { x: 2, y: 6 }, + { x: 2, y: 7 }, + { x: 2, y: 8 }, + { x: 2, y: 9 }, + { x: 2, y: 10 }, + ], + { health: 94 } + ); + const gameState = createGameState( + createBoard( + 11, + [ + { x: 2, y: 3 }, + { x: 4, y: 0 }, + ], + [me, other] + ), + me + ); + + const move = determineMove(gameState); + console.log(move); + expect(move).not.toEqual({ x: 2, y: 5 }); + }); + + it("doesn't decide to die 2", () => { + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ f _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ _ _ _ _ + // _ _ _ _ _ _ _ s h _ _ + // _ _ _ _ _ _ _ s _ _ _ + // _ _ _ _ _ e e e k _ _ + // h = my head, k = enemy head + + const me = createSnake( + [ + { x: 8, y: 2 }, + { x: 7, y: 2 }, + { x: 7, y: 1 }, + ], + { health: 96 } + ); + const other = createSnake( + [ + { x: 8, y: 0 }, + { x: 7, y: 0 }, + { x: 7, y: 0 }, + { x: 7, y: 0 }, + ], + { health: 99 } + ); + const gameState = createGameState( + createBoard(11, [{ x: 5, y: 5 }], [me, other]), + me + ); + console.log(JSON.stringify(gameState, null, 2)); + const move = determineMove(gameState); + console.log(move); + expect(move).not.toEqual({ x: 8, y: 1 }); + }); + }); +}); diff --git a/test/logic.test.ts b/test/logic.test.ts deleted file mode 100644 index 9f43444..0000000 --- a/test/logic.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { info, move } from "../src/logic"; -import { Battlesnake, Coord, GameState, MoveResponse } from "../src/types"; -import { aStar } from "../src/a-star"; - -function createGameState(me: Battlesnake): GameState { - return { - game: { - id: "", - ruleset: { - name: "", - version: "", - }, - timeout: 0, - }, - turn: 0, - board: { - height: 3, - width: 3, - food: [{ x: 2, y: 2 }], - snakes: [me], - hazards: [], - }, - you: me, - }; -} - -function createBattlesnake(id: string, body: Coord[]): Battlesnake { - return { - id: id, - name: id, - health: 0, - body: body, - latency: "", - head: body[0], - length: body.length, - shout: "", - squad: "", - }; -} - -describe("Pathfinding", () => { - it("should return move", () => { - const me = createBattlesnake("me", [{ x: 0, y: 0 }]); - const gameState = createGameState(me); - const result = aStar(gameState, { x: 2, y: 2 }); - console.log(result); - expect(result.id).toEqual(`10`); - }); -}); - -describe("Battlesnake API Version", () => { - it("should be api version 1", () => { - const result = info(); - expect(result.apiversion).toBe("1"); - }); -}); - -describe("Battlesnake Moves", () => { - it("should never move into its own neck", () => { - // Arrange - const me = createBattlesnake("me", [ - { x: 2, y: 0 }, - { x: 1, y: 0 }, - { x: 0, y: 0 }, - ]); - const gameState = createGameState(me); - - // Act 1,000x (this isn't a great way to test, but it's okay for starting out) - for (let i = 0; i < 1000; i++) { - const moveResponse: MoveResponse = move(gameState); - // In this state, we should NEVER move left. - const allowedMoves = ["up", "down", "right"]; - expect(allowedMoves).toContain(moveResponse.move); - } - }); -}); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..3134e58 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,51 @@ +import { v4 as uuid } from "uuid"; +import { Coord, Battlesnake, Board, GameState, Game } from "../src/types"; + +export const createGameState = (board: Board, you: Battlesnake): GameState => { + return { + board, + you, + game: createGame(), + turn: 1, + }; +}; + +export const createBoard = ( + size: number, + food: Coord[] = [], + snakes: Battlesnake[] = [] +): Board => { + return { + height: size, + width: size, + food: food, + snakes, + hazards: [], + }; +}; + +export const createSnake = ( + body: Coord[], + { ...overrides } = {} +): Battlesnake => { + return { + id: uuid(), + name: "test", + health: 100, + body, + latency: "0", + head: body[0], + length: body.length, + shout: "", + squad: "", + ...overrides, + }; +}; + +const createGame = (): Game => { + return { + id: uuid(), + ruleset: { name: "standard", version: "0.1" }, + timeout: 500, + }; +}; diff --git a/test/utils/board.test.ts b/test/utils/board.test.ts new file mode 100644 index 0000000..a6da203 --- /dev/null +++ b/test/utils/board.test.ts @@ -0,0 +1,95 @@ +import { createGrid, BFS, getNeighbors } from "../../src/utils/board"; +import { createBoard, createSnake } from "../utils"; + +describe("utils/board", () => { + describe("createBoard", () => { + it("generates 0x0 grid", () => { + const grid = createGrid(createBoard(0)); + expect(grid.length).toBe(0); + }); + + it("generates 4x4 grid", () => { + const grid = createGrid(createBoard(4)); + expect(grid.length).toBe(4); + expect(grid[0].length).toBe(4); + }); + it("generates grid with food", () => { + const grid = createGrid(createBoard(4, [{ x: 0, y: 0 }])); + const foodTile = grid[0][0]; + expect(foodTile.hasFood).toBe(true); + }); + }); + + describe("getNeighbors", () => { + it("gets left right neighbors", () => { + const snake = createSnake([ + { x: 5, y: 1 }, + { x: 5, y: 0 }, + { x: 4, y: 0 }, + { x: 3, y: 0 }, + { x: 3, y: 1 }, + { x: 3, y: 2 }, + { x: 4, y: 2 }, + { x: 5, y: 2 }, + ]); + + const grid = createGrid(createBoard(11, [], [snake])); + const neighbors = getNeighbors(grid)({ x: 5, y: 1 }); + expect(neighbors).toHaveLength(2); + expect(neighbors[0].coord).toEqual({ x: 6, y: 1 }); + expect(neighbors[1].coord).toEqual({ x: 4, y: 1 }); + }); + }); + + describe("BFS", () => { + it("finds shortest path in simple space", () => { + const head = { x: 2, y: 0 }; + const goal = { x: 2, y: 4 }; + const grid = createGrid(createBoard(5, [goal])); + const path = BFS(grid, head, goal); + expect(path).toHaveLength(5); + expect(path[0].coord).toEqual(head); + expect(path[4].coord).toEqual(goal); + }); + + it("finds shortest path with enemy snake", () => { + const head = { x: 2, y: 0 }; + const goal = { x: 2, y: 4 }; + const snake = [ + { x: 3, y: 3 }, + { x: 2, y: 3 }, + ]; + const enemySnake = createSnake(snake); + const grid = createGrid(createBoard(5, [goal], [enemySnake])); + const path = BFS(grid, head, goal); + + path.forEach((node) => { + snake.forEach((sp) => { + expect(node.coord).not.toEqual(sp); + }); + }); + }); + }); + + describe("getNeighbors", () => { + it("returns values on grid", () => { + const grid = createGrid(createBoard(3)); + const neigbors = getNeighbors(grid)({ x: 0, y: 0 }); + expect(neigbors).toHaveLength(2); + expect(neigbors[0].coord).toEqual({ x: 0, y: 1 }); + expect(neigbors[1].coord).toEqual({ x: 1, y: 0 }); + }); + it("returns nothing for out of bounds coord", () => { + const grid = createGrid(createBoard(3)); + const neigbors = getNeighbors(grid)({ x: 10, y: -10 }); + expect(neigbors).toHaveLength(0); + }); + it("does not return options with snakes", () => { + const snake = createSnake([{ x: 0, y: 1 }]); + const grid = createGrid(createBoard(3, [], [snake])); + const neigbors = getNeighbors(grid)({ x: 0, y: 0 }); + expect(neigbors).toHaveLength(1); + expect(neigbors[0].coord).toEqual({ x: 1, y: 0 }); + }); + }); +}); diff --git a/test/utils/queue.test.ts b/test/utils/queue.test.ts new file mode 100644 index 0000000..f2100c2 --- /dev/null +++ b/test/utils/queue.test.ts @@ -0,0 +1,80 @@ +import { createQueue } from "../../src/utils/queue"; + +describe("utils/queue", () => { + describe("createQueue", () => { + it("has enqueue function", () => { + const q = createQueue(); + expect(q).toHaveProperty("enqueue"); + }); + + it("has dequeue function", () => { + const q = createQueue(); + expect(q).toHaveProperty("dequeue"); + }); + + it("has contents", () => { + const q = createQueue(); + expect(q).toHaveProperty("contents"); + }); + + it("has size", () => { + const q = createQueue(); + expect(q).toHaveProperty("size"); + }); + + it("initializes with default values", () => { + const q = createQueue(); + expect(q.size()).toBe(0); + }); + + it("initializes with values", () => { + const q = createQueue([1]); + expect(q.size()).toBe(1); + expect(q.contents()).toEqual([1]); + }); + it("mutating array returned by contents() should not mutate the queue", () => { + const q = createQueue([1, 2]); + let contents = q.contents(); + contents[1] = 0; + expect(q.contents()).toEqual([1, 2]); + }); + }); + + describe("enqueue", () => { + it("adds item to queue", () => { + const q = createQueue([1, 2]); + q.enqueue(3); + expect(q.size()).toBe(3); + }); + + it("adds item to back of queue", () => { + const q = createQueue([1, 2]); + q.enqueue(3); + expect(q.contents()[2]).toBe(3); + }); + + it("adds item to empty queue", () => { + const q = createQueue(); + q.enqueue(3); + expect(q.contents()[0]).toBe(3); + }); + }); + describe("dequeue", () => { + it("returns undefined for empty queue", () => { + const q = createQueue(); + expect(q.dequeue()).toBeUndefined(); + }); + + it("returns first item in queue", () => { + const q = createQueue([1, 2]); + expect(q.dequeue()).toBe(1); + }); + + it("removes item from queue", () => { + const q = createQueue([1, 2]); + q.dequeue(); + expect(q.size()).toBe(1); + expect(q.contents()).toEqual([2]); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index ba01131..a2a107b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -891,6 +891,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c" integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw== +"@types/uuid@^8.3.4": + version "8.3.4" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" + integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -3927,6 +3932,11 @@ uuid@^3.2.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-to-istanbul@^8.1.0: version "8.1.1" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed" From cabb28621aa823ef3c0e3caf77020d204f84dd2f Mon Sep 17 00:00:00 2001 From: aswanson-nr Date: Mon, 21 Feb 2022 03:13:38 -0800 Subject: [PATCH 2/2] add github workflow to run unit tests --- .github/workflows/unit_test.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/unit_test.yml diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml new file mode 100644 index 0000000..22ca549 --- /dev/null +++ b/.github/workflows/unit_test.yml @@ -0,0 +1,33 @@ +name: tests + +on: + pull_request: + - main + + +jobs: + test: + name: unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 16 + + - name: Cache dependencies + id: yarn-cache + uses: actions/cache@v2 + with: + path: '**/node_modules' + key: ${{ runner.os }}-node-modules-${{ hashFiles('**/yarn.lock') }} + + - name: Install dependencies + if: steps.yarn-cache.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - name: run tests + run: yarn test