Compare commits
5 Commits
41f117e3a8
...
c4238d19e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
c4238d19e8
|
|||
|
afaf6c7c63
|
|||
|
1cd0f1ed1d
|
|||
|
8f6a2ef674
|
|||
|
5328252cf1
|
+3
-16
@@ -31,7 +31,6 @@ from server.services import (
|
||||
DashboardQueryService,
|
||||
)
|
||||
|
||||
|
||||
class Server:
|
||||
default_snake_config = {
|
||||
'apiversion': '1',
|
||||
@@ -97,7 +96,6 @@ class Server:
|
||||
self._startup_worker_metrics_cleared = False
|
||||
|
||||
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
||||
self.snake_builder = SnakeBuilder
|
||||
self.snake_version = self._get_snake_version()
|
||||
self.gameplay_database = None
|
||||
if gameplay_db_enabled:
|
||||
@@ -214,20 +212,9 @@ class Server:
|
||||
|
||||
def _get_snake_version(self) -> str:
|
||||
configured_version = SnakeBuilder.get_version(self.snake_type)
|
||||
if configured_version:
|
||||
return configured_version
|
||||
|
||||
try:
|
||||
snake = SnakeBuilder.build(self.snake_type)
|
||||
except Exception:
|
||||
if configured_version is None:
|
||||
return self.default_snake_config['version']
|
||||
|
||||
version = getattr(snake, 'version', None)
|
||||
if version is None:
|
||||
version = getattr(snake, 'VERSION', None)
|
||||
if not version:
|
||||
return self.default_snake_config['version']
|
||||
return str(version)
|
||||
return str(configured_version)
|
||||
|
||||
def _get_stale_game_timeout_sec(self) -> int:
|
||||
return max(30, env_int('SNAKE_STUCK_GAME_TIMEOUT_SEC', 180))
|
||||
@@ -236,7 +223,7 @@ class Server:
|
||||
self.store_game_state = True
|
||||
|
||||
def _cleanup_database(self):
|
||||
storage = StorageLoader.build(self.storage_type)()
|
||||
storage = StorageLoader.build(self.storage_type)
|
||||
return storage.cleanup()
|
||||
|
||||
async def _on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||
|
||||
@@ -25,7 +25,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
server.metrics_collector.record_http_request('start')
|
||||
await server.game_runtime.prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
await server.game_runtime.create_game_board(game_state, snake_builder=server.snake_builder)
|
||||
await server.game_runtime.create_game_board(game_state)
|
||||
await server.gameplay_tracking.record_gameplay_start(game_state)
|
||||
await await_log(server.logger.info(f'GAME START: {game_state['game']}'))
|
||||
return 'ok'
|
||||
@@ -35,7 +35,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
server.metrics_collector.record_http_request('move')
|
||||
game_state = await request.get_json()
|
||||
move_started = time.perf_counter()
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state, snake_builder=server.snake_builder))
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state))
|
||||
next_move = game_board.snake_neat_make_a_move()
|
||||
await server.game_runtime.persist_game_board(game_state['game']['id'], game_board)
|
||||
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
|
||||
@@ -53,7 +53,7 @@ def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
await server.game_runtime.prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
if server.store_game_state:
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state, snake_builder=server.snake_builder, end=True))
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state, end=True))
|
||||
if server.check_tls_security:
|
||||
await game_board.save(
|
||||
StorageLoader.build(server.storage_type),
|
||||
|
||||
+4
-2
@@ -10,15 +10,17 @@ class RunConfig(TypedDict):
|
||||
port: int
|
||||
debug: bool
|
||||
|
||||
|
||||
def build_server_from_env(default_snake_type:str) -> Server:
|
||||
data_path = str(Path(__file__).resolve().parent.parent)
|
||||
backend_default = os.environ.get('BACKEND', 'memory')
|
||||
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
game_state_backend = os.environ.get('GAME_STATE_BACKEND', 'memory')
|
||||
game_state_backend = os.environ.get('GAME_STATE_BACKEND', backend_default)
|
||||
game_state_redis_url = os.environ.get('GAME_STATE_REDIS_URL', redis_url)
|
||||
game_state_ttl_sec = env_int('GAME_STATE_TTL_SEC', 900)
|
||||
|
||||
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
||||
if metrics_backend is None:
|
||||
metrics_backend = os.environ.get('BACKEND', None)
|
||||
if metrics_backend is None:
|
||||
metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory')
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
from typing import cast
|
||||
from typing import Protocol, cast
|
||||
import time
|
||||
|
||||
from server.metrics import MetricsCollector
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
from server.storage import StorageLoader
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
class GameStateStoreLike(Protocol):
|
||||
async def save(self, game_id: str, game_board: GameBoard) -> None: ...
|
||||
|
||||
async def load(self, game_id: str) -> object | None: ...
|
||||
|
||||
async def delete(self, game_id: str) -> None: ...
|
||||
|
||||
class GameRuntimeService:
|
||||
def __init__(self, game_state_store:StorageLoader, snake_type:str, game_state_local_cache:bool, stale_game_timeout_sec:int):
|
||||
def __init__(self, game_state_store:GameStateStoreLike, snake_type:str, game_state_local_cache:bool, stale_game_timeout_sec:int):
|
||||
self.game_state_store = game_state_store
|
||||
self.snake_type = snake_type
|
||||
self.game_state_local_cache = game_state_local_cache
|
||||
@@ -22,7 +28,7 @@ class GameRuntimeService:
|
||||
def attach_metrics_collector(self, metrics_collector:MetricsCollector) -> None:
|
||||
self.metrics_collector = metrics_collector
|
||||
|
||||
async def create_game_board(self, game_state:dict, snake_builder:SnakeBuilder) -> GameBoard:
|
||||
async def create_game_board(self, game_state:dict) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
new_game_board = GameBoard(
|
||||
game_id=game_id,
|
||||
@@ -31,7 +37,7 @@ class GameRuntimeService:
|
||||
ruleset=game_state['game']['ruleset'],
|
||||
source=game_state['game']['source'],
|
||||
map=game_state['game']['map'],
|
||||
snake_class=snake_builder.build(self.snake_type),
|
||||
snake_class=SnakeBuilder.build(self.snake_type),
|
||||
)
|
||||
await new_game_board.start_game(game_state)
|
||||
|
||||
@@ -57,7 +63,7 @@ class GameRuntimeService:
|
||||
self.game_last_seen_unix.pop(game_id, None)
|
||||
await self.game_state_store.delete(game_id)
|
||||
|
||||
async def get_game_board(self, game_state:dict, snake_builder:SnakeBuilder, end:bool=False) -> GameBoard:
|
||||
async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
game_board:GameBoard
|
||||
if self.game_state_local_cache and game_id in self.running_games:
|
||||
@@ -69,13 +75,12 @@ class GameRuntimeService:
|
||||
if self.game_state_local_cache:
|
||||
self.running_games[game_id] = game_board
|
||||
else:
|
||||
game_board = await self.create_game_board(game_state, snake_builder)
|
||||
game_board = await self.create_game_board(game_state)
|
||||
if self.metrics_collector is not None:
|
||||
await self.metrics_collector.record_game_autocreated()
|
||||
|
||||
if not end:
|
||||
self.game_move_counts[game_id] = self.game_move_counts.get(game_id, 0) + 1
|
||||
|
||||
self.game_last_seen_unix[game_id] = int(time.time())
|
||||
|
||||
game_board.read_game_data(game_state)
|
||||
|
||||
@@ -382,6 +382,19 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.snake-turn-cell::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--turn-color, transparent);
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.snake-turn-cell.snake-turn-ur::after { border-top-right-radius: 42%; }
|
||||
.snake-turn-cell.snake-turn-ul::after { border-top-left-radius: 42%; }
|
||||
.snake-turn-cell.snake-turn-dr::after { border-bottom-right-radius: 42%; }
|
||||
.snake-turn-cell.snake-turn-dl::after { border-bottom-left-radius: 42%; }
|
||||
|
||||
.food {
|
||||
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
|
||||
background-repeat: no-repeat;
|
||||
@@ -404,6 +417,7 @@
|
||||
);
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.snake-you { background: var(--you); }
|
||||
.snake-enemy { background: var(--enemy); }
|
||||
@@ -1112,6 +1126,90 @@
|
||||
}
|
||||
}
|
||||
|
||||
function parseViewBox(svgEl) {
|
||||
const raw = String(svgEl.getAttribute("viewBox") || "").trim();
|
||||
const parts = raw.split(/\s+/).map((item) => Number(item));
|
||||
if (parts.length !== 4 || parts.some((v) => Number.isNaN(v))) {
|
||||
return { minX: 0, minY: 0, width: 100, height: 100 };
|
||||
}
|
||||
return { minX: parts[0], minY: parts[1], width: parts[2], height: parts[3] };
|
||||
}
|
||||
|
||||
function groupLooksOffCanvas(groupEl, viewBox) {
|
||||
const attrNames = new Set(["x", "y", "cx", "cy", "x1", "y1", "x2", "y2", "d", "points"]);
|
||||
const allElements = [groupEl, ...groupEl.querySelectorAll("*")];
|
||||
let farOutsideCount = 0;
|
||||
let numericCount = 0;
|
||||
const minAllowedX = viewBox.minX - Math.max(40, viewBox.width * 0.8);
|
||||
const minAllowedY = viewBox.minY - Math.max(40, viewBox.height * 0.8);
|
||||
const maxAllowedX = viewBox.minX + viewBox.width + Math.max(40, viewBox.width * 0.8);
|
||||
const maxAllowedY = viewBox.minY + viewBox.height + Math.max(40, viewBox.height * 0.8);
|
||||
|
||||
for (const node of allElements) {
|
||||
for (const attr of node.getAttributeNames()) {
|
||||
if (!attrNames.has(attr)) continue;
|
||||
const value = node.getAttribute(attr);
|
||||
if (!value) continue;
|
||||
const matches = value.match(/-?\d*\.?\d+/g);
|
||||
if (!matches) continue;
|
||||
|
||||
for (let idx = 0; idx < matches.length; idx += 1) {
|
||||
const num = Number(matches[idx]);
|
||||
if (Number.isNaN(num)) continue;
|
||||
numericCount += 1;
|
||||
const isXCoord = idx % 2 === 0;
|
||||
if (isXCoord) {
|
||||
if (num < minAllowedX || num > maxAllowedX) farOutsideCount += 1;
|
||||
} else {
|
||||
if (num < minAllowedY || num > maxAllowedY) farOutsideCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (numericCount < 10) return false;
|
||||
return farOutsideCount / numericCount > 0.55;
|
||||
}
|
||||
|
||||
function maxNestedGroupDepth(groupEl) {
|
||||
let maxDepth = 1;
|
||||
const stack = [{ node: groupEl, depth: 1 }];
|
||||
while (stack.length > 0) {
|
||||
const entry = stack.pop();
|
||||
if (!entry) continue;
|
||||
maxDepth = Math.max(maxDepth, entry.depth);
|
||||
for (const child of Array.from(entry.node.children)) {
|
||||
if (!child.tagName || child.tagName.toLowerCase() !== "g") continue;
|
||||
stack.push({ node: child, depth: entry.depth + 1 });
|
||||
}
|
||||
}
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
function normalizeHeadSvgMarkup(svgMarkup) {
|
||||
if (!svgMarkup) return null;
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const parsed = parser.parseFromString(svgMarkup, "image/svg+xml");
|
||||
const svgEl = parsed.querySelector("svg");
|
||||
if (!svgEl) return svgMarkup;
|
||||
|
||||
const topLevelGroups = Array.from(svgEl.children).filter((el) => el.tagName && el.tagName.toLowerCase() === "g");
|
||||
if (topLevelGroups.length > 1) {
|
||||
const viewBox = parseViewBox(svgEl);
|
||||
const firstGroup = topLevelGroups[0];
|
||||
const deeplyNestedGroup = maxNestedGroupDepth(firstGroup) >= 3;
|
||||
if (groupLooksOffCanvas(firstGroup, viewBox) || deeplyNestedGroup) {
|
||||
firstGroup.remove();
|
||||
}
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(svgEl);
|
||||
} catch {
|
||||
return svgMarkup;
|
||||
}
|
||||
}
|
||||
|
||||
async function preloadReplaySvgs() {
|
||||
if (!replay || !Array.isArray(replay.turns)) return;
|
||||
const urls = new Set();
|
||||
@@ -1135,7 +1233,7 @@
|
||||
if (type === "head") {
|
||||
const svgMarkup = svgCache.get(iconUrl);
|
||||
if (svgMarkup) {
|
||||
layer.innerHTML = svgMarkup;
|
||||
layer.innerHTML = normalizeHeadSvgMarkup(svgMarkup);
|
||||
const svgEl = layer.querySelector("svg");
|
||||
if (svgEl) {
|
||||
svgEl.style.width = "100%";
|
||||
@@ -1678,14 +1776,56 @@
|
||||
if (snakeBody.has(key)) {
|
||||
const hasHeadIcon = headIconByCell.has(key);
|
||||
const hasTailIcon = tailIconByCell.has(key);
|
||||
const bodyColor = snakeBody.get(key);
|
||||
if (hasHeadIcon || hasTailIcon) {
|
||||
//cell.style.background = "var(--cell)";
|
||||
} else {
|
||||
cell.style.background = snakeBody.get(key);
|
||||
cell.style.background = bodyColor;
|
||||
}
|
||||
if (selectedSnakeId && snakeIdByCell.get(key) !== selectedSnakeId) {
|
||||
cell.style.opacity = "0.2";
|
||||
}
|
||||
|
||||
if (!snakeHead.has(key) && !snakeTail.has(key)) {
|
||||
const snakeId = snakeIdByCell.get(key);
|
||||
if (snakeId) {
|
||||
const up = snakeIdByCell.get(cellKey(x, y + 1)) === snakeId;
|
||||
const down = snakeIdByCell.get(cellKey(x, y - 1)) === snakeId;
|
||||
const left = snakeIdByCell.get(cellKey(x - 1, y)) === snakeId;
|
||||
const right = snakeIdByCell.get(cellKey(x + 1, y)) === snakeId;
|
||||
|
||||
if (up && right && !down && !left) {
|
||||
cell.classList.add("snake-turn-cell");
|
||||
cell.classList.add("snake-turn-dl");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
} else if (up && left && !down && !right) {
|
||||
cell.classList.add("snake-turn-cell");
|
||||
cell.classList.add("snake-turn-dr");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
} else if (down && right && !up && !left) {
|
||||
cell.classList.add("snake-turn-cell");
|
||||
cell.classList.add("snake-turn-ul");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
} else if (down && left && !up && !right) {
|
||||
cell.classList.add("snake-turn-cell");
|
||||
cell.classList.add("snake-turn-ur");
|
||||
cell.style.setProperty("--turn-color", bodyColor);
|
||||
cell.style.background = "var(--cell)";
|
||||
}
|
||||
|
||||
const bridgeShadows = [];
|
||||
if (up) bridgeShadows.push(`0 -1px 0 ${bodyColor}`);
|
||||
if (down) bridgeShadows.push(`0 1px 0 ${bodyColor}`);
|
||||
if (left) bridgeShadows.push(`-1px 0 0 ${bodyColor}`);
|
||||
if (right) bridgeShadows.push(`1px 0 0 ${bodyColor}`);
|
||||
if (bridgeShadows.length > 0) {
|
||||
cell.style.boxShadow = bridgeShadows.join(", ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (snakeTail.has(key)) {
|
||||
cell.classList.add(snakeTail.get(key));
|
||||
|
||||
Reference in New Issue
Block a user