201 lines
7.1 KiB
JavaScript
201 lines
7.1 KiB
JavaScript
const BOARD_SIZE = 24;
|
|||
|
|
const MAX_TICKS = 100_000;
|
||
|
|
const SESSION_TTL_MS = 2 * 60 * 60 * 1000;
|
||
|
|
const DIRECTIONS = {
|
||
|
|
up: { x: 0, y: -1, opposite: "down" },
|
||
|
|
down: { x: 0, y: 1, opposite: "up" },
|
||
|
|
left: { x: -1, y: 0, opposite: "right" },
|
||
|
|
right: { x: 1, y: 0, opposite: "left" },
|
||
|
|
};
|
||
|
|
|
||
|
|
function makeRng(seed) {
|
||
|
|
let value = seed >>> 0;
|
||
|
|
return () => {
|
||
|
|
value ^= value << 13;
|
||
|
|
value ^= value >>> 17;
|
||
|
|
value ^= value << 5;
|
||
|
|
return (value >>> 0) / 0x100000000;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function cellKey(cell) {
|
||
|
|
return `${cell.x},${cell.y}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function chooseFood(snake, rng) {
|
||
|
|
const occupied = new Set(snake.map(cellKey));
|
||
|
|
const free = [];
|
||
|
|
for (let y = 0; y < BOARD_SIZE; y += 1) {
|
||
|
|
for (let x = 0; x < BOARD_SIZE; x += 1) {
|
||
|
|
if (!occupied.has(`${x},${y}`)) free.push({ x, y });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return free.length ? free[Math.floor(rng() * free.length)] : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function initialState(seed) {
|
||
|
|
const snake = [{ x: 12, y: 12 }, { x: 11, y: 12 }, { x: 10, y: 12 }];
|
||
|
|
const rng = makeRng(seed);
|
||
|
|
return { snake, direction: "right", food: chooseFood(snake, rng), score: 0, rng };
|
||
|
|
}
|
||
|
|
|
||
|
|
function step(state) {
|
||
|
|
const vector = DIRECTIONS[state.direction];
|
||
|
|
const head = { x: state.snake[0].x + vector.x, y: state.snake[0].y + vector.y };
|
||
|
|
const ate = state.food && head.x === state.food.x && head.y === state.food.y;
|
||
|
|
const collisionBody = ate ? state.snake : state.snake.slice(0, -1);
|
||
|
|
const collided = head.x < 0 || head.y < 0 || head.x >= BOARD_SIZE || head.y >= BOARD_SIZE ||
|
||
|
|
collisionBody.some((cell) => cell.x === head.x && cell.y === head.y);
|
||
|
|
if (collided) return { dead: true, won: false };
|
||
|
|
|
||
|
|
state.snake.unshift(head);
|
||
|
|
if (ate) {
|
||
|
|
state.score += 1;
|
||
|
|
state.food = chooseFood(state.snake, state.rng);
|
||
|
|
if (!state.food) return { dead: true, won: true };
|
||
|
|
} else {
|
||
|
|
state.snake.pop();
|
||
|
|
}
|
||
|
|
return { dead: false, won: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
function validateMoves(value, ticks) {
|
||
|
|
if (!Array.isArray(value) || value.length > ticks) throw new Error("Invalid move log");
|
||
|
|
let lastTick = 0;
|
||
|
|
return value.map((move) => {
|
||
|
|
const tick = Number(move?.tick);
|
||
|
|
const direction = move?.direction;
|
||
|
|
if (!Number.isInteger(tick) || tick <= lastTick || tick > ticks || !DIRECTIONS[direction]) {
|
||
|
|
throw new Error("Invalid move log");
|
||
|
|
}
|
||
|
|
lastTick = tick;
|
||
|
|
return { tick, direction };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function replay(seed, ticks, moves) {
|
||
|
|
const state = initialState(seed);
|
||
|
|
let moveIndex = 0;
|
||
|
|
for (let tick = 1; tick <= ticks; tick += 1) {
|
||
|
|
if (moveIndex < moves.length && moves[moveIndex].tick === tick) {
|
||
|
|
const next = moves[moveIndex].direction;
|
||
|
|
if (DIRECTIONS[state.direction].opposite === next) throw new Error("Invalid direction change");
|
||
|
|
state.direction = next;
|
||
|
|
moveIndex += 1;
|
||
|
|
}
|
||
|
|
const outcome = step(state);
|
||
|
|
if (outcome.dead) {
|
||
|
|
if (tick !== ticks) throw new Error("Run continued after game over");
|
||
|
|
return { score: state.score, won: outcome.won };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw new Error("Run has not ended");
|
||
|
|
}
|
||
|
|
|
||
|
|
function leaderboard(ctx) {
|
||
|
|
return ctx.db.prepare(`
|
||
|
|
SELECT caller_id AS callerId, username, score, played_at AS playedAt
|
||
|
|
FROM snake_scores
|
||
|
|
ORDER BY score DESC, played_at ASC
|
||
|
|
LIMIT 10
|
||
|
|
`).all().map((row) => ({
|
||
|
|
player: String(row.username || `Player #${row.callerId}`),
|
||
|
|
score: Number(row.score),
|
||
|
|
playedAt: Number(row.playedAt),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
export default {
|
||
|
|
setup(ctx) {
|
||
|
|
ctx.db.exec(`
|
||
|
|
CREATE TABLE IF NOT EXISTS snake_games (
|
||
|
|
id TEXT PRIMARY KEY,
|
||
|
|
caller_id INTEGER NOT NULL,
|
||
|
|
seed INTEGER NOT NULL,
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
completed INTEGER NOT NULL DEFAULT 0
|
||
|
|
);
|
||
|
|
CREATE TABLE IF NOT EXISTS snake_scores (
|
||
|
|
caller_id INTEGER PRIMARY KEY,
|
||
|
|
username TEXT,
|
||
|
|
score INTEGER NOT NULL,
|
||
|
|
played_at INTEGER NOT NULL
|
||
|
|
);
|
||
|
|
CREATE INDEX IF NOT EXISTS snake_games_created_at ON snake_games(created_at);
|
||
|
|
`);
|
||
|
|
const scoreColumns = ctx.db.prepare("PRAGMA table_info(snake_scores)").all();
|
||
|
|
if (!scoreColumns.some((column) => column.name === "username")) {
|
||
|
|
ctx.db.exec("ALTER TABLE snake_scores ADD COLUMN username TEXT");
|
||
|
|
}
|
||
|
|
ctx.db.prepare("DELETE FROM snake_games WHERE created_at < ?").run(Date.now() - SESSION_TTL_MS);
|
||
|
|
ctx.log.info("Snake arcade ready");
|
||
|
|
},
|
||
|
|
|
||
|
|
rpc: {
|
||
|
|
start(ctx, _params, meta) {
|
||
|
|
const callerId = Number(meta.caller.id);
|
||
|
|
const id = crypto.randomUUID();
|
||
|
|
const seed = crypto.getRandomValues(new Uint32Array(1))[0];
|
||
|
|
const createdAt = Date.now();
|
||
|
|
ctx.db.prepare("DELETE FROM snake_games WHERE caller_id = ? AND completed = 0").run(callerId);
|
||
|
|
ctx.db.prepare("INSERT INTO snake_games (id, caller_id, seed, created_at) VALUES (?, ?, ?, ?)")
|
||
|
|
.run(id, callerId, seed, createdAt);
|
||
|
|
const state = initialState(seed);
|
||
|
|
return {
|
||
|
|
gameId: id,
|
||
|
|
seed,
|
||
|
|
boardSize: BOARD_SIZE,
|
||
|
|
snake: state.snake,
|
||
|
|
food: state.food,
|
||
|
|
direction: state.direction,
|
||
|
|
tickMs: 115,
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
leaderboard(ctx, _params, meta) {
|
||
|
|
const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim();
|
||
|
|
ctx.db.prepare("UPDATE snake_scores SET username = ? WHERE caller_id = ?")
|
||
|
|
.run(username, Number(meta.caller.id));
|
||
|
|
return { entries: leaderboard(ctx) };
|
||
|
|
},
|
||
|
|
|
||
|
|
submit(ctx, params, meta) {
|
||
|
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
||
|
|
const ticks = Number(params?.ticks);
|
||
|
|
if (!gameId || !Number.isInteger(ticks) || ticks < 1 || ticks > MAX_TICKS) {
|
||
|
|
throw new Error("Invalid completed run");
|
||
|
|
}
|
||
|
|
|
||
|
|
const game = ctx.db.prepare(
|
||
|
|
"SELECT id, caller_id AS callerId, seed, created_at AS createdAt, completed FROM snake_games WHERE id = ?",
|
||
|
|
).get(gameId);
|
||
|
|
if (!game || Number(game.callerId) !== Number(meta.caller.id)) throw new Error("Game not found");
|
||
|
|
if (game.completed) throw new Error("Game was already submitted");
|
||
|
|
if (Date.now() - Number(game.createdAt) > SESSION_TTL_MS) throw new Error("Game expired");
|
||
|
|
|
||
|
|
const moves = validateMoves(params?.moves, ticks);
|
||
|
|
const result = replay(Number(game.seed), ticks, moves);
|
||
|
|
const username = String(meta.caller.username || `Player #${meta.caller.id}`).trim();
|
||
|
|
const playedAt = Date.now();
|
||
|
|
const save = ctx.db.transaction(() => {
|
||
|
|
const updated = ctx.db.prepare("UPDATE snake_games SET completed = 1 WHERE id = ? AND completed = 0").run(gameId);
|
||
|
|
if (updated.changes !== 1) throw new Error("Game was already submitted");
|
||
|
|
ctx.db.prepare("UPDATE snake_scores SET username = ? WHERE caller_id = ?")
|
||
|
|
.run(username, Number(meta.caller.id));
|
||
|
|
ctx.db.prepare(`
|
||
|
|
INSERT INTO snake_scores (caller_id, username, score, played_at) VALUES (?, ?, ?, ?)
|
||
|
|
ON CONFLICT(caller_id) DO UPDATE SET
|
||
|
|
score = excluded.score,
|
||
|
|
played_at = excluded.played_at
|
||
|
|
WHERE excluded.score > snake_scores.score
|
||
|
|
`).run(Number(meta.caller.id), username, result.score, playedAt);
|
||
|
|
});
|
||
|
|
save();
|
||
|
|
|
||
|
|
const entries = leaderboard(ctx);
|
||
|
|
ctx.broadcast("leaderboard", { entries });
|
||
|
|
return { score: result.score, won: result.won, entries };
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|