Skip to content

Commit

Permalink
Merge pull request #3 from nosnaws/smarter-better-faster-stronger
Browse files Browse the repository at this point in the history
smarter better faster stronger
  • Loading branch information
nosnaws authored Feb 21, 2022
2 parents ec2aa4d + cabb286 commit e44bcc4
Show file tree
Hide file tree
Showing 18 changed files with 935 additions and 218 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/unit_test.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@

node_modules
build
newrelic_agent.log
yarn-error.log
2 changes: 0 additions & 2 deletions .replit

This file was deleted.

1 change: 0 additions & 1 deletion CODEOWNERS

This file was deleted.

13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/a-star.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export const snakeLength = (
return snake ? snake.length : undefined;
};

export const prop = <T, K extends keyof T>(key: K) => (obj: T) => obj[key];
const prop = <T, K extends keyof T>(key: K) => (obj: T) => obj[key];
export const areCoordsEqual = (a: Coord, b: Coord) =>
a.x === b.x && a.y === b.y;

Expand Down
121 changes: 121 additions & 0 deletions src/heuristic_snake.ts
Original file line number Diff line number Diff line change
@@ -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;
};
153 changes: 21 additions & 132 deletions src/logic.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = <T>(array: Array<T>): Array<T> =>
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 }));
Loading

0 comments on commit e44bcc4

Please sign in to comment.