Compare commits
54 Commits
3cb3517892
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 60fe19c61c | |||
|
79c6b00ace
|
|||
|
3614625c56
|
|||
|
341bb27278
|
|||
|
a62501cf22
|
|||
|
f6e19e18e6
|
|||
|
f479541c04
|
|||
|
f0d62a6049
|
|||
|
d2e1f2560e
|
|||
|
739c0520f9
|
|||
|
03968fecdf
|
|||
|
f4c0ad193e
|
|||
|
898f8106ed
|
|||
|
dfa658e4ce
|
|||
|
abed259129
|
|||
|
8c4f83fb4b
|
|||
|
1b8fa67059
|
|||
|
a2a79b7efb
|
|||
|
59d01428a9
|
|||
|
3da10189b7
|
|||
|
a8eb6a4447
|
|||
|
5c1875be60
|
|||
|
7c990f98fc
|
|||
|
dd3547678c
|
|||
|
0af5f58688
|
|||
|
fdc22af4cf
|
|||
|
ed41c32ad8
|
|||
|
6c34df103e
|
|||
|
af7df92f4d
|
|||
|
fbc7a50f34
|
|||
|
1a1bcd8ec3
|
|||
|
fe999c11f4
|
|||
|
01343472df
|
|||
|
c4238d19e8
|
|||
|
afaf6c7c63
|
|||
|
1cd0f1ed1d
|
|||
|
8f6a2ef674
|
|||
|
5328252cf1
|
|||
|
41f117e3a8
|
|||
|
43c7720480
|
|||
|
98be2fe6fe
|
|||
|
30eb17bb83
|
|||
|
d89986dba9
|
|||
|
0ebb04f0a2
|
|||
|
097a7f295a
|
|||
|
4626a491f5
|
|||
|
e7d0227cf9
|
|||
|
b7c6a0e345
|
|||
|
050dd1083c
|
|||
|
2d603a3b0b
|
|||
|
b9b02cf273
|
|||
|
4151810f1b
|
|||
|
2601c2dcff
|
|||
|
efe15dd8e7
|
@@ -9,6 +9,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Rewrite SSH submodule URLs to HTTPS for CI
|
||||||
|
run: |
|
||||||
|
git config --global url."https://x-token:${{ secrets.ACTION_ACCESS_TOKEN }}@git.yiprawr.dev/".insteadOf "git@git.yiprawr.dev:"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -5,12 +5,10 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
data/
|
|
||||||
.env
|
.env
|
||||||
.tools/
|
.testing/
|
||||||
|
|
||||||
dbschema/migrations/
|
dbschema/migrations/
|
||||||
|
|
||||||
*.jsonl
|
*.jsonl
|
||||||
/dataset/
|
/dataset/
|
||||||
models/
|
|
||||||
|
|||||||
@@ -1,13 +1,43 @@
|
|||||||
|
# Stage 1 — compile sqlite-zstd + SQLite 3.49.1
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS sqlite-zstd-builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl unzip git libzstd-dev pkg-config build-essential ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Compile SQLite 3.49.1 as a shared library so the runtime image can use it
|
||||||
|
RUN curl -fsSL https://www.sqlite.org/2025/sqlite-amalgamation-3490100.zip -o /tmp/sqlite.zip && \
|
||||||
|
unzip /tmp/sqlite.zip -d /tmp/ && \
|
||||||
|
cd /tmp/sqlite-amalgamation-3490100 && \
|
||||||
|
gcc -O2 -shared -fPIC -o /usr/local/lib/libsqlite3.so.0 sqlite3.c -ldl -lpthread
|
||||||
|
|
||||||
|
# Install Rust and compile sqlite-zstd (uses bundled SQLite 3.49.1 — no patching needed)
|
||||||
|
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
|
|
||||||
|
RUN git clone --depth=1 --branch v0.3.5 https://github.com/phiresky/sqlite-zstd.git /tmp/sqlite-zstd && \
|
||||||
|
cd /tmp/sqlite-zstd && \
|
||||||
|
cargo build --release --features build_extension && \
|
||||||
|
cp target/release/libsqlite_zstd.so /usr/local/lib/libsqlite_zstd.so
|
||||||
|
|
||||||
|
# Stage 2 — runtime image
|
||||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libzstd1 && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Replace system SQLite 3.46.1 with 3.49.1 so it matches the extension
|
||||||
|
COPY --from=sqlite-zstd-builder /usr/local/lib/libsqlite3.so.0 /usr/local/lib/libsqlite3.so.0
|
||||||
|
RUN echo "/usr/local/lib" > /etc/ld.so.conf.d/local.conf && ldconfig
|
||||||
|
|
||||||
|
COPY --from=sqlite-zstd-builder /usr/local/lib/libsqlite_zstd.so /usr/local/lib/libsqlite_zstd.so
|
||||||
|
|
||||||
# Install app
|
# Install app
|
||||||
COPY . /app
|
COPY . /app
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN uv sync --no-config --frozen --compile-bytecode
|
RUN uv sync --no-config --frozen --compile-bytecode
|
||||||
|
|
||||||
# Starten Sie Ihre Anwendung
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD [".venv/bin/hypercorn", "asgi:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--access-logfile", "-"]
|
CMD [".venv/bin/hypercorn", "asgi:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--access-logfile", "-"]
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
battlesnake:
|
battlesnake:
|
||||||
image: daniel156161/battlesnake
|
image: daniel156161/battlesnake
|
||||||
@@ -7,7 +5,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- 8000:8000
|
- 8000:8000
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ${DOCKER_DATA_PATH}:/app/data
|
||||||
build:
|
build:
|
||||||
context: ./
|
context: ./
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ set dotenv-required := true
|
|||||||
# Use zsh
|
# Use zsh
|
||||||
set shell := ["bash", "-cu"]
|
set shell := ["bash", "-cu"]
|
||||||
|
|
||||||
BATTLESNAKE_CLI_DIR := ".tools/battlesnake-cli"
|
BATTLESNAKE_CLI_DIR := ".testing/tools/battlesnake-cli"
|
||||||
BATTLESNAKE_CLI_BIN := ".tools/battlesnake-cli/battlesnake"
|
BATTLESNAKE_CLI_BIN := ".testing/tools/battlesnake-cli/battlesnake"
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Default
|
# Default
|
||||||
@@ -112,7 +112,7 @@ test-local-4 mode="standard" map="standard" base_port="9101" snake="BestBattleSn
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BATTLESNAKE_CLI="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
BATTLESNAKE_CLI="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
||||||
LOG_DIR="{{justfile_directory()}}/.tools/snake-logs"
|
LOG_DIR="{{justfile_directory()}}/.testing/tools/snake-logs"
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
pids=()
|
pids=()
|
||||||
@@ -162,17 +162,17 @@ test-local-4 mode="standard" map="standard" base_port="9101" snake="BestBattleSn
|
|||||||
# Dataset helpers
|
# Dataset helpers
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
export-dataset input="data" output="data/dataset/good_moves.jsonl":
|
export-dataset input=".testing/data" output=".testing/data/dataset/good_moves.jsonl":
|
||||||
python -m server.DatasetExporter --input "{{input}}" --output "{{output}}"
|
python -m server.DatasetExporter --input "{{input}}" --output "{{output}}"
|
||||||
|
|
||||||
curate-dataset input="good_moves-*.jsonl" output="data/dataset/best_moves.jsonl" min_turn="6" late_turn="20" max_safe_options="2" min_score="3" append="false" archive="false" archive_dir="":
|
curate-dataset input="good_moves-*.jsonl" output=".testing/data/dataset/best_moves.jsonl" min_turn="6" late_turn="20" max_safe_options="2" min_score="3" append="false" archive="false" archive_dir="":
|
||||||
FLAGS=""; if [ "{{append}}" = "true" ]; then FLAGS="$FLAGS --append"; fi; if [ "{{archive}}" = "true" ]; then FLAGS="$FLAGS --archive-input"; fi; if [ -n "{{archive_dir}}" ]; then FLAGS="$FLAGS --archive-dir {{archive_dir}}"; fi; python -m server.DatasetCurator --input "{{input}}" --output "{{output}}" --min-turn "{{min_turn}}" --late-turn "{{late_turn}}" --max-safe-options "{{max_safe_options}}" --min-score "{{min_score}}" $FLAGS
|
FLAGS=""; if [ "{{append}}" = "true" ]; then FLAGS="$FLAGS --append"; fi; if [ "{{archive}}" = "true" ]; then FLAGS="$FLAGS --archive-input"; fi; if [ -n "{{archive_dir}}" ]; then FLAGS="$FLAGS --archive-dir {{archive_dir}}"; fi; python -m server.DatasetCurator --input "{{input}}" --output "{{output}}" --min-turn "{{min_turn}}" --late-turn "{{late_turn}}" --max-safe-options "{{max_safe_options}}" --min-score "{{min_score}}" $FLAGS
|
||||||
|
|
||||||
analyze-dataset input="good_moves-*.jsonl" output="":
|
analyze-dataset input="good_moves-*.jsonl" output="":
|
||||||
if [ -n "{{output}}" ]; then python -m server.DatasetStats --input "{{input}}" --output "{{output}}"; else python -m server.DatasetStats --input "{{input}}"; fi
|
if [ -n "{{output}}" ]; then python -m server.DatasetStats --input "{{input}}" --output "{{output}}"; else python -m server.DatasetStats --input "{{input}}"; fi
|
||||||
|
|
||||||
train-ai input="dataset/best_moves.jsonl" rl_input="dataset/rl_bootstrap.jsonl" output="models/battlesnake_softmax_v2.json" eval_split="0.2" seed="42" epochs="14" lr="0.08":
|
train-ai input=".testing/data/dataset/best_moves.jsonl" rl_input=".testing/data/dataset/rl_bootstrap.jsonl" output=".testing/models/battlesnake_softmax_v2.json" eval_split="0.2" seed="42" epochs="14" lr="0.08":
|
||||||
if [ -f "{{rl_input}}" ]; then python -m server.TrainBattleSnakeAI --input "{{input}}" --input "{{rl_input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; else python -m server.TrainBattleSnakeAI --input "{{input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; fi
|
if [ -f "{{rl_input}}" ]; then python -m server.TrainBattleSnakeAI --input "{{input}}" --input "{{rl_input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; else python -m server.TrainBattleSnakeAI --input "{{input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; fi
|
||||||
|
|
||||||
run-trained model="models/battlesnake_softmax_v2.json" port="8000":
|
run-trained model=".testing/models/battlesnake_softmax_v2.json" port="8000":
|
||||||
TRAINED_SNAKE_MODEL="{{model}}" SNAKE="TrainedBattleSnake" PORT="{{port}}" "{{justfile_directory()}}/main.py"
|
TRAINED_SNAKE_MODEL="{{model}}" SNAKE="TrainedBattleSnake" PORT="{{port}}" "{{justfile_directory()}}/main.py"
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
# To get you started we've included code to prevent your Battlesnake from moving backwards.
|
# To get you started we've included code to prevent your Battlesnake from moving backwards.
|
||||||
# For more info see docs.battlesnake.com
|
# For more info see docs.battlesnake.com
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
||||||
from server.bootstrap import build_run_config, build_server_from_env
|
from server.bootstrap import build_run_config, build_server_from_env
|
||||||
|
|
||||||
@@ -20,12 +22,15 @@ import os
|
|||||||
|
|
||||||
# Start server when `python main.py` is run
|
# Start server when `python main.py` is run
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
||||||
if os.environ.get("CREATE_ENV_FILE", None):
|
if os.environ.get("CREATE_ENV_FILE", None):
|
||||||
CreateEnvironmentFile.load_dotenv({
|
CreateEnvironmentFile.load_dotenv({
|
||||||
"STORE_GAME_HISTORY": True,
|
"STORE_GAME_HISTORY": True,
|
||||||
"DEBUG": True,
|
"DEBUG": True,
|
||||||
"SNAKE": "TemplateSnake",
|
"SNAKE": "TemplateSnake",
|
||||||
})
|
})
|
||||||
|
else:
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
server = build_server_from_env(default_snake_type="TemplateSnake")
|
server = build_server_from_env(default_snake_type="TemplateSnake")
|
||||||
asyncio.run(server.run(**build_run_config()))
|
asyncio.run(server.run(**build_run_config()))
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ dependencies = [
|
|||||||
"gel>=3.1.0",
|
"gel>=3.1.0",
|
||||||
"redis>=5.2.1",
|
"redis>=5.2.1",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
|
"python-dotenv>=1.2.2",
|
||||||
|
"asyncpg>=0.31.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import urlopen
|
||||||
|
import shutil
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
BASE_URL = "https://media.battlesnake.com/"
|
||||||
|
S3_NS = {"s3": "http://doc.s3.amazonaws.com/2006-03-01"}
|
||||||
|
DEFAULT_PREFIXES = ("snakes/heads/", "snakes/tails/")
|
||||||
|
ALLOWED_EXTENSIONS = {".svg", ".png", ".webp"}
|
||||||
|
|
||||||
|
def build_list_url(prefix:str, marker:str|None) -> str:
|
||||||
|
query = {"prefix": prefix}
|
||||||
|
if marker:
|
||||||
|
query["marker"] = marker
|
||||||
|
return f"{BASE_URL}?{urlencode(query)}"
|
||||||
|
|
||||||
|
def list_keys_for_prefix(prefix:str) -> list[str]:
|
||||||
|
keys: list[str] = []
|
||||||
|
marker: str | None = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
url = build_list_url(prefix=prefix, marker=marker)
|
||||||
|
with urlopen(url) as response:
|
||||||
|
xml_bytes = response.read()
|
||||||
|
|
||||||
|
root = ET.fromstring(xml_bytes)
|
||||||
|
for key_node in root.findall("s3:Contents/s3:Key", S3_NS):
|
||||||
|
key = (key_node.text or "").strip()
|
||||||
|
if key and not key.endswith("/"):
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
truncated_text = (
|
||||||
|
root.findtext("s3:IsTruncated", default="false", namespaces=S3_NS)
|
||||||
|
or "false"
|
||||||
|
).lower()
|
||||||
|
is_truncated = truncated_text == "true"
|
||||||
|
if not is_truncated:
|
||||||
|
break
|
||||||
|
|
||||||
|
next_marker = (
|
||||||
|
root.findtext("s3:NextMarker", default="", namespaces=S3_NS) or ""
|
||||||
|
).strip()
|
||||||
|
if next_marker:
|
||||||
|
marker = next_marker
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_key = keys[-1] if keys else None
|
||||||
|
if not last_key:
|
||||||
|
break
|
||||||
|
marker = last_key
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def keep_customization_key(key:str) -> bool:
|
||||||
|
if not key.startswith(DEFAULT_PREFIXES):
|
||||||
|
return False
|
||||||
|
suffix = Path(key).suffix.lower()
|
||||||
|
return suffix in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def to_output_path(output_root:Path, key:str) -> Path:
|
||||||
|
if key.startswith("snakes/heads/"):
|
||||||
|
relative = key.removeprefix("snakes/")
|
||||||
|
elif key.startswith("snakes/tails/"):
|
||||||
|
relative = key.removeprefix("snakes/")
|
||||||
|
else:
|
||||||
|
relative = key
|
||||||
|
return output_root / relative
|
||||||
|
|
||||||
|
def download_file(url:str, output_file:Path) -> None:
|
||||||
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with urlopen(url) as response, output_file.open("wb") as target:
|
||||||
|
shutil.copyfileobj(response, target)
|
||||||
|
|
||||||
|
def prune_output(output_root:Path, wanted_files:set[Path]) -> int:
|
||||||
|
removed = 0
|
||||||
|
if not output_root.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for file_path in output_root.rglob("*"):
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
if file_path not in wanted_files:
|
||||||
|
file_path.unlink()
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
for directory in sorted(
|
||||||
|
(p for p in output_root.rglob("*") if p.is_dir()), reverse=True
|
||||||
|
):
|
||||||
|
if any(directory.iterdir()):
|
||||||
|
continue
|
||||||
|
directory.rmdir()
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def collect_customization_keys(prefixes:Iterable[str]) -> list[str]:
|
||||||
|
all_keys: list[str] = []
|
||||||
|
for prefix in prefixes:
|
||||||
|
all_keys.extend(list_keys_for_prefix(prefix))
|
||||||
|
return [key for key in sorted(set(all_keys)) if keep_customization_key(key)]
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download Battlesnake snake customization assets (heads/tails) from media.battlesnake.com",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default="data/battlesnake-customizations",
|
||||||
|
help="Output directory (default: data/battlesnake-customizations)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--overwrite",
|
||||||
|
action="store_true",
|
||||||
|
help="Overwrite files that already exist",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prune",
|
||||||
|
action="store_true",
|
||||||
|
help="Delete files in output directory that are not snake customizations",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = parse_args()
|
||||||
|
output_root = Path(args.output).resolve()
|
||||||
|
|
||||||
|
keys = collect_customization_keys(DEFAULT_PREFIXES)
|
||||||
|
if not keys:
|
||||||
|
print("No customization files found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
downloaded = 0
|
||||||
|
skipped = 0
|
||||||
|
wanted_files: set[Path] = set()
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
file_url = f"{BASE_URL}{key}"
|
||||||
|
output_file = to_output_path(output_root, key)
|
||||||
|
wanted_files.add(output_file)
|
||||||
|
|
||||||
|
if output_file.exists() and not args.overwrite:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
download_file(file_url, output_file)
|
||||||
|
downloaded += 1
|
||||||
|
|
||||||
|
removed = prune_output(output_root, wanted_files) if args.prune else 0
|
||||||
|
|
||||||
|
print(f"Output directory : {output_root}")
|
||||||
|
print(f"Files discovered : {len(keys)}")
|
||||||
|
print(f"Downloaded : {downloaded}")
|
||||||
|
print(f"Skipped existing : {skipped}")
|
||||||
|
if args.prune:
|
||||||
|
print(f"Removed non-customization files: {removed}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
from snakes.TemplateSnake import TemplateSnake
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
class GameBoard:
|
class GameBoard:
|
||||||
def __init__(self, game_id:str, width:int, height:int, ruleset:dict, source:str, map:str, snake_class):
|
def __init__(self, game_id:str, width:int, height:int, ruleset:dict, source:str, map:str, snake_class:TemplateSnake):
|
||||||
self.id = game_id
|
self.id = game_id
|
||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
@@ -12,12 +13,17 @@ class GameBoard:
|
|||||||
self.winner_snake_names = None
|
self.winner_snake_names = None
|
||||||
self.now_date = datetime.now()
|
self.now_date = datetime.now()
|
||||||
self.turns = []
|
self.turns = []
|
||||||
self.is_ladder = True if source == "ladder" else False
|
# Accept old "ladder" value and current API values "league"/"arena" as competitive sources
|
||||||
|
self.is_ladder = source in {'ladder', 'league', 'arena'}
|
||||||
self.ruleset = ruleset
|
self.ruleset = ruleset
|
||||||
self.map = map
|
self.map = map
|
||||||
self.url = self._get_game_url(True if ruleset["version"] == "cli" else False)
|
self.url = self._get_game_url(True if ruleset["version"] == "cli" else False)
|
||||||
self.timeout = 500
|
self.timeout = 500
|
||||||
|
|
||||||
|
# Snake Helper Functions
|
||||||
|
def get_snake_name_and_version(self) -> tuple[str, str]:
|
||||||
|
return self.snake_class.name, self.snake_class.version
|
||||||
|
|
||||||
# Setter Functions
|
# Setter Functions
|
||||||
def _set_snakes(self, snakes:list[dict]):
|
def _set_snakes(self, snakes:list[dict]):
|
||||||
self.other_snakes = [ x for x in snakes if x["id"] != self.my_snake["id"] ]
|
self.other_snakes = [ x for x in snakes if x["id"] != self.my_snake["id"] ]
|
||||||
@@ -145,6 +151,11 @@ class GameBoard:
|
|||||||
|
|
||||||
return {"name": self.type, "is_ladder": self.is_ladder}
|
return {"name": self.type, "is_ladder": self.is_ladder}
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
state = self.__dict__.copy()
|
||||||
|
state['turns'] = [] # strip turn history — grows linearly, not needed for move computation
|
||||||
|
return state
|
||||||
|
|
||||||
async def save(self, store_class, **kwargs):
|
async def save(self, store_class, **kwargs):
|
||||||
store = store_class(**kwargs)
|
store = store_class(**kwargs)
|
||||||
await store.save(self)
|
await store.save(self)
|
||||||
|
|||||||
@@ -1,55 +1,56 @@
|
|||||||
from quart_common.web.logger import build_logger, await_log
|
from quart_common.web.logger import build_logger, await_log
|
||||||
from server.Files import read_file
|
from quart_common.web.env import env_bool, env_int
|
||||||
|
|
||||||
from server.game_state_store import GameStateStoreBuilder
|
|
||||||
from server.GameBoard import GameBoard
|
|
||||||
|
|
||||||
from snakes import SnakeBuilder
|
from snakes import SnakeBuilder
|
||||||
|
|
||||||
from server.storage import StorageLoader
|
from server.database import (
|
||||||
|
GameplayDatabase,
|
||||||
|
GameplayBackendBuilder,
|
||||||
|
StorageLoader,
|
||||||
|
)
|
||||||
from server.metrics import (
|
from server.metrics import (
|
||||||
MetricsStoreBuilder,
|
MetricsStoreBuilder,
|
||||||
MetricsCollector,
|
MetricsCollector,
|
||||||
)
|
)
|
||||||
|
|
||||||
from quart import Quart, request, jsonify
|
import asyncio, signal, logging, os, re, time
|
||||||
import logging, json, os, re, time
|
from quart import Quart
|
||||||
from typing import cast
|
|
||||||
|
from server.blueprints import (
|
||||||
|
create_battlesnake_blueprint,
|
||||||
|
create_metrics_blueprint,
|
||||||
|
create_dashboard_blueprint,
|
||||||
|
)
|
||||||
|
from server.services import (
|
||||||
|
DashboardEventsService,
|
||||||
|
DashboardWebSocketHub,
|
||||||
|
GameRuntimeService,
|
||||||
|
GameplayTrackingService,
|
||||||
|
DashboardQueryService,
|
||||||
|
)
|
||||||
|
|
||||||
class Server:
|
class Server:
|
||||||
default_snake_config = {
|
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_backend:str='sqlite', gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000, gameplay_db_pg_dsn:str|None=None):
|
||||||
'apiversion': '1',
|
|
||||||
'author': '',
|
|
||||||
'color': '#888888',
|
|
||||||
'head': 'default',
|
|
||||||
'tail': 'default',
|
|
||||||
'version': '1.0.0',
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, game_state_backend:str='memory', game_state_redis_url:str='redis://localhost:6379/0', game_state_ttl_sec:int=900, game_state_local_cache:bool=True, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None):
|
|
||||||
self.debug = debug
|
self.debug = debug
|
||||||
|
self.data_path = data_path
|
||||||
|
|
||||||
self.snake_type = snake_type
|
self.snake_type = snake_type
|
||||||
self.storage_type = storage_type
|
self.storage_type = storage_type
|
||||||
|
|
||||||
self.config_file = os.path.join(data_path, 'data', 'snake-config.json')
|
self.config_file = os.path.join(data_path, 'data', 'snake-config.json')
|
||||||
self.data_path = data_path
|
|
||||||
self.check_tls_security = check_tls_security
|
self.check_tls_security = check_tls_security
|
||||||
|
|
||||||
self.store_game_state = False
|
self.store_game_state = False
|
||||||
normalized_backend = (game_state_backend or 'memory').strip().lower()
|
|
||||||
self.game_state_local_cache = (game_state_local_cache and normalized_backend != 'memory')
|
|
||||||
self.game_state_store = GameStateStoreBuilder.build(
|
|
||||||
backend=game_state_backend,
|
|
||||||
redis_url=game_state_redis_url,
|
|
||||||
ttl_seconds=game_state_ttl_sec,
|
|
||||||
)
|
|
||||||
metrics_backend_normalized = (metrics_backend or 'memory').strip().lower()
|
metrics_backend_normalized = (metrics_backend or 'memory').strip().lower()
|
||||||
|
self.metrics_backend_normalized = metrics_backend_normalized
|
||||||
|
self.metrics_redis_url = metrics_redis_url
|
||||||
self.stale_game_timeout_sec = self._get_stale_game_timeout_sec()
|
self.stale_game_timeout_sec = self._get_stale_game_timeout_sec()
|
||||||
|
|
||||||
self.running_games:dict[str, GameBoard] = {}
|
self.game_runtime = GameRuntimeService(
|
||||||
self.game_move_counts:dict[str, int] = {}
|
snake_type=self.snake_type,
|
||||||
self.game_last_seen_unix:dict[str, int] = {}
|
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||||
|
)
|
||||||
|
self.dashboard_ws_hub = DashboardWebSocketHub()
|
||||||
|
|
||||||
self.metrics_collector = MetricsCollector(
|
self.metrics_collector = MetricsCollector(
|
||||||
metrics_manager=MetricsStoreBuilder.build(
|
metrics_manager=MetricsStoreBuilder.build(
|
||||||
@@ -58,86 +59,56 @@ class Server:
|
|||||||
ttl_seconds=metrics_ttl_sec,
|
ttl_seconds=metrics_ttl_sec,
|
||||||
key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'),
|
key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'),
|
||||||
),
|
),
|
||||||
game_state_local_cache=self.game_state_local_cache,
|
|
||||||
metrics_backend=metrics_backend_normalized,
|
metrics_backend=metrics_backend_normalized,
|
||||||
game_state_backend=game_state_backend,
|
|
||||||
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||||
game_last_seen_unix=self.game_last_seen_unix,
|
game_last_seen_unix=self.game_runtime.game_last_seen_unix,
|
||||||
game_move_counts=self.game_move_counts,
|
game_move_counts=self.game_runtime.game_move_counts,
|
||||||
)
|
)
|
||||||
self.clear_worker_metrics_on_startup = self._env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True)
|
|
||||||
self.worker_metrics_startup_lock_ttl_sec = self._env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300)
|
self.game_runtime.attach_metrics_collector(self.metrics_collector)
|
||||||
self._startup_worker_metrics_cleared = False
|
self._startup_worker_metrics_cleared = False
|
||||||
|
|
||||||
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
||||||
self.snake_version = self._get_snake_version()
|
self.snake_version = self._get_snake_version()
|
||||||
|
|
||||||
self.app = Quart('Battlesnake')
|
self.gameplay_database = None
|
||||||
|
if gameplay_db_enabled:
|
||||||
# info is called when you create your Battlesnake on play.battlesnake.com
|
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
||||||
# and controls your Battlesnake's appearance
|
self.gameplay_database = GameplayDatabase(
|
||||||
# TIP: If you open your Battlesnake URL in a browser you should see this data
|
backend=GameplayBackendBuilder.build(
|
||||||
@self.app.get('/')
|
backend=gameplay_db_backend,
|
||||||
async def on_info():
|
db_path=db_path,
|
||||||
self.metrics_collector.record_http_request('info')
|
busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||||
snake_config = await self._read_json_config_or_create()
|
pg_dsn=gameplay_db_pg_dsn,
|
||||||
|
|
||||||
await await_log(self.logger.info(f'INFO Snake: {snake_config}'))
|
|
||||||
return snake_config
|
|
||||||
|
|
||||||
# start is called when your Battlesnake begins a game
|
|
||||||
@self.app.post('/start')
|
|
||||||
async def on_start():
|
|
||||||
self.metrics_collector.record_http_request('start')
|
|
||||||
await self._prune_stale_games()
|
|
||||||
game_state = await request.get_json()
|
|
||||||
await self._create_game_board(game_state)
|
|
||||||
await await_log(self.logger.info(f'GAME START: {game_state['game']}'))
|
|
||||||
return 'ok'
|
|
||||||
|
|
||||||
# move is called when your Battlesnake game is running game
|
|
||||||
@self.app.post('/move')
|
|
||||||
async def on_move():
|
|
||||||
self.metrics_collector.record_http_request('move')
|
|
||||||
game_state = await request.get_json()
|
|
||||||
move_started = time.perf_counter()
|
|
||||||
game_board = cast(GameBoard, await self._get_game_board(game_state))
|
|
||||||
next_move = game_board.snake_neat_make_a_move()
|
|
||||||
await self._persist_game_board(game_state['game']['id'], game_board)
|
|
||||||
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
|
||||||
await self.metrics_collector.record_move(next_move, elapsed_ms)
|
|
||||||
|
|
||||||
if self.debug:
|
|
||||||
await await_log(self.logger.debug(f'TURN: {game_state['turn']:3}, MOVE: {next_move:5}'))
|
|
||||||
|
|
||||||
return {'move': next_move}
|
|
||||||
|
|
||||||
# end is called when your Battlesnake finishes a game
|
|
||||||
@self.app.post('/end')
|
|
||||||
async def on_end():
|
|
||||||
self.metrics_collector.record_http_request('end')
|
|
||||||
await self._prune_stale_games()
|
|
||||||
game_state = await request.get_json()
|
|
||||||
if self.store_game_state:
|
|
||||||
game_board = cast(GameBoard, await self._get_game_board(game_state, end=True))
|
|
||||||
if self.check_tls_security:
|
|
||||||
await game_board.save(
|
|
||||||
StorageLoader.build(self.storage_type),
|
|
||||||
file_path=os.path.join(self.data_path, 'data'),
|
|
||||||
database=os.getenv('EDGEDB_DATABASE', None),
|
|
||||||
tls_security=None,
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
await game_board.save(
|
|
||||||
StorageLoader.build(self.storage_type),
|
|
||||||
file_path=os.path.join(self.data_path, 'data'),
|
|
||||||
database=os.getenv('EDGEDB_DATABASE', None),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await await_log(self.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}'))
|
self.gameplay_tracking = GameplayTrackingService(
|
||||||
await self._delete_game_board(game_state)
|
gameplay_database=self.gameplay_database,
|
||||||
await self.metrics_collector.record_game_end(game_state)
|
logger=self.logger,
|
||||||
return 'ok'
|
)
|
||||||
|
self.dashboard_query = DashboardQueryService(
|
||||||
|
gameplay_database=self.gameplay_database,
|
||||||
|
ws_hub=self.dashboard_ws_hub,
|
||||||
|
logger=self.logger,
|
||||||
|
dashboard_running_game_stale_sec=600,
|
||||||
|
)
|
||||||
|
self.dashboard_events_service = DashboardEventsService(
|
||||||
|
enabled=(self.metrics_backend_normalized == 'redis' and env_bool('DASHBOARD_EVENTS_ENABLED', True)),
|
||||||
|
redis_url=self.metrics_redis_url,
|
||||||
|
channel= os.getenv('DASHBOARD_EVENTS_CHANNEL', 'snake:dashboard:events'),
|
||||||
|
event_origin=f'worker-{os.getpid()}-{int(time.time() * 1000)}',
|
||||||
|
shutdown_event=self.dashboard_ws_hub.shutdown_event,
|
||||||
|
on_notice=self._on_dashboard_games_update_notice,
|
||||||
|
logger=self.logger,
|
||||||
|
)
|
||||||
|
self.dashboard_query.set_publish_notice(self.dashboard_events_service.publish_notice)
|
||||||
|
|
||||||
|
self.app = Quart('Battlesnake', template_folder=os.path.join(data_path, 'templates', 'side'), static_folder=os.path.join(data_path, 'templates', 'files'))
|
||||||
|
|
||||||
|
self.app.register_blueprint(create_battlesnake_blueprint(self))
|
||||||
|
self.app.register_blueprint(create_metrics_blueprint(self))
|
||||||
|
self.app.register_blueprint(create_dashboard_blueprint(self))
|
||||||
|
|
||||||
@self.app.after_request
|
@self.app.after_request
|
||||||
async def identify_server(response):
|
async def identify_server(response):
|
||||||
@@ -149,179 +120,70 @@ class Server:
|
|||||||
if self._startup_worker_metrics_cleared:
|
if self._startup_worker_metrics_cleared:
|
||||||
return
|
return
|
||||||
self._startup_worker_metrics_cleared = True
|
self._startup_worker_metrics_cleared = True
|
||||||
if self.clear_worker_metrics_on_startup:
|
|
||||||
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(self.worker_metrics_startup_lock_ttl_sec)
|
if env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True):
|
||||||
|
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300))
|
||||||
if should_clear:
|
if should_clear:
|
||||||
await self.metrics_collector.clear_worker_metrics()
|
await self.metrics_collector.clear_worker_metrics()
|
||||||
|
await self.dashboard_events_service.start_listener()
|
||||||
|
|
||||||
|
if self.gameplay_database is not None:
|
||||||
|
await self.gameplay_database.initialize()
|
||||||
|
|
||||||
@self.app.after_serving
|
@self.app.after_serving
|
||||||
async def shutdown_state_storage():
|
async def shutdown_state_storage():
|
||||||
await self.game_state_store.close()
|
await self.dashboard_events_service.stop_listener()
|
||||||
await self.metrics_collector.close()
|
await self.metrics_collector.close()
|
||||||
|
if self.gameplay_database is not None:
|
||||||
@self.app.get('/cleanup')
|
await self.gameplay_database.close()
|
||||||
async def cleanup():
|
|
||||||
results = self._cleanup_database()
|
|
||||||
return jsonify(data=json.loads(results), status=200)
|
|
||||||
|
|
||||||
@self.app.get('/metrics')
|
|
||||||
async def metrics():
|
|
||||||
snapshot = await self.metrics_collector.build_snapshot(self.game_last_seen_unix, self.game_move_counts)
|
|
||||||
return jsonify(snapshot)
|
|
||||||
|
|
||||||
@self.app.get('/metrics/prometheus')
|
|
||||||
async def metrics_prometheus():
|
|
||||||
snapshot = await self.metrics_collector.build_snapshot(self.game_last_seen_unix, self.game_move_counts)
|
|
||||||
return (
|
|
||||||
self.metrics_collector.build_prometheus_metrics(snapshot),
|
|
||||||
200,
|
|
||||||
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
||||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
await await_log(self.logger.info(f'Running Battlesnake at http://{host}:{port} with the {" ".join(re.findall("[A-Z][^A-Z]*", self.snake_type))}'))
|
installed_signal_handlers:list[signal.Signals] = []
|
||||||
await self.app.run_task(host=host, port=port, debug=debug)
|
shutdown_event = asyncio.Event()
|
||||||
|
|
||||||
async def _read_json_config_or_create(self) -> dict[str, str]:
|
def on_shutdown_signal() -> None:
|
||||||
snake_config = cast(dict[str, str]|None, await read_file(self.config_file, json.load))
|
self.dashboard_ws_hub.request_shutdown()
|
||||||
if not snake_config:
|
shutdown_event.set()
|
||||||
return await self._override_snake_config_with_environment_variables(self.default_snake_config)
|
|
||||||
return await self._override_snake_config_with_environment_variables(snake_config)
|
|
||||||
|
|
||||||
async def _override_snake_config_with_environment_variables(self, config:dict[str, str]) -> dict[str, str]:
|
async def shutdown_trigger() -> None:
|
||||||
config['version'] = self.snake_version
|
await shutdown_event.wait()
|
||||||
|
|
||||||
for key in ('author', 'color', 'head', 'tail'):
|
for shutdown_signal in (signal.SIGINT, signal.SIGTERM):
|
||||||
value = os.environ.get(f'SNAKE_{key.upper()}')
|
try:
|
||||||
if value is not None:
|
loop.add_signal_handler(shutdown_signal, on_shutdown_signal)
|
||||||
config[key] = value
|
installed_signal_handlers.append(shutdown_signal)
|
||||||
|
except (NotImplementedError, RuntimeError):
|
||||||
|
continue
|
||||||
|
|
||||||
version_override = os.environ.get('SNAKE_VERSION')
|
await await_log(self.logger.info(f'Running Battlesnake at http://{host}:{port} with the {' '.join(re.findall('[A-Z][^A-Z]*', self.snake_type))}'))
|
||||||
if version_override is not None:
|
try:
|
||||||
config['version'] = version_override
|
await self.app.run_task(host=host, port=port, debug=debug, shutdown_trigger=shutdown_trigger)
|
||||||
|
finally:
|
||||||
return config
|
self.dashboard_ws_hub.request_shutdown()
|
||||||
|
for shutdown_signal in installed_signal_handlers:
|
||||||
|
try:
|
||||||
|
loop.remove_signal_handler(shutdown_signal)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
def _get_snake_version(self) -> str:
|
def _get_snake_version(self) -> str:
|
||||||
configured_version = SnakeBuilder.get_version(self.snake_type)
|
configured_version = SnakeBuilder.get_version(self.snake_type)
|
||||||
if configured_version:
|
if configured_version is None:
|
||||||
return configured_version
|
return str(SnakeBuilder.get_version('TemplateSnake'))
|
||||||
|
return str(configured_version)
|
||||||
try:
|
|
||||||
snake = SnakeBuilder.build(self.snake_type)
|
|
||||||
except Exception:
|
|
||||||
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)
|
|
||||||
|
|
||||||
def _get_stale_game_timeout_sec(self) -> int:
|
def _get_stale_game_timeout_sec(self) -> int:
|
||||||
value = os.getenv('SNAKE_STUCK_GAME_TIMEOUT_SEC', '180')
|
return max(30, env_int('SNAKE_STUCK_GAME_TIMEOUT_SEC', 180))
|
||||||
try:
|
|
||||||
return max(30, int(value))
|
|
||||||
except ValueError:
|
|
||||||
return 180
|
|
||||||
|
|
||||||
def _env_bool(self, name:str, default:bool=False) -> bool:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
|
|
||||||
|
|
||||||
def _env_int(self, name: str, default: int) -> int:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
async def _create_game_board(self, game_state:dict) -> GameBoard:
|
|
||||||
game_id = game_state['game']['id']
|
|
||||||
new_game_board = GameBoard(
|
|
||||||
game_id=game_id,
|
|
||||||
width=game_state['board']['width'],
|
|
||||||
height=game_state['board']['height'],
|
|
||||||
ruleset=game_state['game']['ruleset'],
|
|
||||||
source=game_state['game']['source'],
|
|
||||||
map=game_state['game']['map'],
|
|
||||||
snake_class=SnakeBuilder.build(self.snake_type),
|
|
||||||
)
|
|
||||||
await new_game_board.start_game(game_state)
|
|
||||||
|
|
||||||
if self.game_state_local_cache:
|
|
||||||
self.running_games[game_id] = new_game_board
|
|
||||||
await self.game_state_store.save(game_id, new_game_board)
|
|
||||||
self.game_move_counts[game_id] = 0
|
|
||||||
self.game_last_seen_unix[game_id] = int(time.time())
|
|
||||||
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
|
|
||||||
return new_game_board
|
|
||||||
|
|
||||||
async def _persist_game_board(self, game_id:str, game_board:GameBoard):
|
|
||||||
if self.game_state_local_cache:
|
|
||||||
self.running_games[game_id] = game_board
|
|
||||||
await self.game_state_store.save(game_id, game_board)
|
|
||||||
|
|
||||||
async def _delete_game_board(self, game_state:dict):
|
|
||||||
game_id = game_state['game']['id']
|
|
||||||
self.running_games.pop(game_id, None)
|
|
||||||
self.game_move_counts.pop(game_id, None)
|
|
||||||
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, 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:
|
|
||||||
game_board = self.running_games[game_id]
|
|
||||||
else:
|
|
||||||
persisted_board = await self.game_state_store.load(game_id)
|
|
||||||
if persisted_board is not None:
|
|
||||||
game_board = cast(GameBoard, persisted_board)
|
|
||||||
if self.game_state_local_cache:
|
|
||||||
self.running_games[game_id] = game_board
|
|
||||||
else:
|
|
||||||
game_board = await self._create_game_board(game_state)
|
|
||||||
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)
|
|
||||||
if end:
|
|
||||||
game_board.end_game(game_state)
|
|
||||||
await self._persist_game_board(game_id, game_board)
|
|
||||||
|
|
||||||
return game_board
|
|
||||||
|
|
||||||
def enable_store_game_state(self):
|
def enable_store_game_state(self):
|
||||||
self.store_game_state = True
|
self.store_game_state = True
|
||||||
|
|
||||||
def _cleanup_database(self):
|
def _cleanup_database(self):
|
||||||
storage = StorageLoader.build(self.storage_type)()
|
storage = StorageLoader.build(self.storage_type)
|
||||||
return storage.cleanup()
|
return storage.cleanup()
|
||||||
|
|
||||||
async def _prune_stale_games(self):
|
async def _on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||||
if not self.game_last_seen_unix:
|
await self.dashboard_query.on_dashboard_games_update_notice(trigger)
|
||||||
return
|
|
||||||
|
|
||||||
now = int(time.time())
|
|
||||||
stale_ids = [
|
|
||||||
game_id
|
|
||||||
for game_id, last_seen in self.game_last_seen_unix.items()
|
|
||||||
if now - last_seen >= self.stale_game_timeout_sec
|
|
||||||
]
|
|
||||||
for game_id in stale_ids:
|
|
||||||
self.running_games.pop(game_id, None)
|
|
||||||
self.game_move_counts.pop(game_id, None)
|
|
||||||
self.game_last_seen_unix.pop(game_id, None)
|
|
||||||
await self.metrics_collector.record_stuck_removed()
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .battlesnake import create_battlesnake_blueprint
|
||||||
|
from .metrics import create_metrics_blueprint
|
||||||
|
from .dashboard import create_dashboard_blueprint
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
import asyncio, json, time, os
|
||||||
|
|
||||||
|
from quart import Blueprint, request, jsonify
|
||||||
|
|
||||||
|
from quart_common.web.decorators import require_user_agent
|
||||||
|
from quart_common.web.logger import await_log
|
||||||
|
from server.database import StorageLoader
|
||||||
|
from snakes import DEFAULT_SNAKE_CONFIG
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
from server.Files import read_file
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.Server import Server
|
||||||
|
|
||||||
|
def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||||
|
blueprint = Blueprint('battlesnake', __name__)
|
||||||
|
|
||||||
|
async def _override_snake_config_with_environment_variables(config:dict[str, str]) -> dict[str, str]:
|
||||||
|
print(config)
|
||||||
|
config['version'] = server.snake_version
|
||||||
|
|
||||||
|
for key in ('author', 'color', 'head', 'tail'):
|
||||||
|
value = os.environ.get(f'SNAKE_{key.upper()}')
|
||||||
|
if value is not None:
|
||||||
|
config[key] = value
|
||||||
|
|
||||||
|
version_override = os.environ.get('SNAKE_VERSION')
|
||||||
|
if version_override is not None:
|
||||||
|
config['version'] = version_override
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
@blueprint.get('/')
|
||||||
|
async def on_info():
|
||||||
|
server.metrics_collector.record_http_request('info')
|
||||||
|
|
||||||
|
snake_config = cast(dict[str, str]|None, await read_file(server.config_file, json.load))
|
||||||
|
if not snake_config:
|
||||||
|
snake_json = await _override_snake_config_with_environment_variables(DEFAULT_SNAKE_CONFIG)
|
||||||
|
else:
|
||||||
|
snake_json = await _override_snake_config_with_environment_variables(snake_config)
|
||||||
|
|
||||||
|
await await_log(server.logger.info(f'INFO Snake: {snake_json}'))
|
||||||
|
return snake_json
|
||||||
|
|
||||||
|
@blueprint.post('/start')
|
||||||
|
@require_user_agent("BattlesnakeEngine", abort_code=404)
|
||||||
|
async def on_start():
|
||||||
|
server.metrics_collector.record_http_request('start')
|
||||||
|
await server.game_runtime.prune_stale_games()
|
||||||
|
game_state = await request.get_json()
|
||||||
|
game_board = await server.game_runtime.create_game_board(game_state)
|
||||||
|
await server.gameplay_tracking.record_gameplay_start(game_state, game_board)
|
||||||
|
await await_log(server.logger.info(f'GAME START: {game_state['game']}'))
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
@blueprint.post('/move')
|
||||||
|
@require_user_agent("BattlesnakeEngine", abort_code=404)
|
||||||
|
async def on_move():
|
||||||
|
server.metrics_collector.record_http_request('move')
|
||||||
|
game_state = await request.get_json()
|
||||||
|
move_started = time.perf_counter()
|
||||||
|
|
||||||
|
game_id = game_state['game']['id']
|
||||||
|
timeout_ms = int(game_state.get('game', {}).get('timeout', 500))
|
||||||
|
budget_sec = max(0.05, (timeout_ms - 50) / 1000.0)
|
||||||
|
|
||||||
|
next_move = None
|
||||||
|
game_board = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(budget_sec):
|
||||||
|
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state))
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
next_move = await loop.run_in_executor(None, game_board.snake_neat_make_a_move)
|
||||||
|
except TimeoutError:
|
||||||
|
await await_log(server.logger.warning(f'MOVE TIMEOUT: turn={game_state.get("turn")}, game={game_id}, returning fallback {next_move!r}'))
|
||||||
|
|
||||||
|
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
|
||||||
|
elapsed_ms = (time.perf_counter() - move_started) * 1000.0
|
||||||
|
await server.metrics_collector.record_move(next_move, elapsed_ms)
|
||||||
|
|
||||||
|
if server.debug:
|
||||||
|
await await_log(server.logger.debug(f'TURN: {game_state['turn']:3}, MOVE: {next_move:5}'))
|
||||||
|
|
||||||
|
return {'move': next_move}
|
||||||
|
|
||||||
|
@blueprint.post('/end')
|
||||||
|
@require_user_agent("BattlesnakeEngine", abort_code=404)
|
||||||
|
async def on_end():
|
||||||
|
server.metrics_collector.record_http_request('end')
|
||||||
|
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, end=True))
|
||||||
|
if server.check_tls_security:
|
||||||
|
await game_board.save(
|
||||||
|
StorageLoader.build(server.storage_type),
|
||||||
|
file_path=os.path.join(server.data_path, 'data'),
|
||||||
|
database=os.getenv('EDGEDB_DATABASE', None),
|
||||||
|
tls_security=None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await game_board.save(
|
||||||
|
StorageLoader.build(server.storage_type),
|
||||||
|
file_path=os.path.join(server.data_path, 'data'),
|
||||||
|
database=os.getenv('EDGEDB_DATABASE', None),
|
||||||
|
)
|
||||||
|
|
||||||
|
await server.gameplay_tracking.record_gameplay_end(game_state)
|
||||||
|
await server.dashboard_query.push_dashboard_games_update(game_state)
|
||||||
|
await await_log(server.logger.info(f'GAME ENDED: Winner is {[x['name'] for x in game_state['board']['snakes']]}'))
|
||||||
|
await server.game_runtime.delete_game_board(game_state)
|
||||||
|
await server.metrics_collector.record_game_end(game_state)
|
||||||
|
return 'ok'
|
||||||
|
|
||||||
|
# @blueprint.get('/cleanup')
|
||||||
|
# async def cleanup():
|
||||||
|
# results = server._cleanup_database()
|
||||||
|
# return jsonify(data=json.loads(results), status=200)
|
||||||
|
|
||||||
|
return blueprint
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
import asyncio, json, os
|
||||||
|
|
||||||
|
from quart import (
|
||||||
|
Blueprint,
|
||||||
|
render_template,
|
||||||
|
send_from_directory,
|
||||||
|
request,
|
||||||
|
websocket,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.Server import Server
|
||||||
|
|
||||||
|
def create_dashboard_blueprint(server:'Server') -> Blueprint:
|
||||||
|
blueprint = Blueprint('dashboard', __name__)
|
||||||
|
|
||||||
|
@blueprint.get('/dashboard')
|
||||||
|
async def dashboard_view():
|
||||||
|
initial_game_id = request.args.get('game_id', '')
|
||||||
|
initial_summary = await server.dashboard_query.get_dashboard_summary()
|
||||||
|
initial_games = await server.dashboard_query.get_dashboard_games(limit=100)
|
||||||
|
return await render_template(
|
||||||
|
'dashboard.htm',
|
||||||
|
initial_game_id=initial_game_id,
|
||||||
|
initial_summary=initial_summary,
|
||||||
|
initial_games=initial_games,
|
||||||
|
battlesnake_url=os.getenv('BATTLESNAKE_GAMEBOARD_URL', 'https://play.battlesnake.com/game')
|
||||||
|
)
|
||||||
|
|
||||||
|
@blueprint.get('/dashboard/customizations/<path:asset_path>')
|
||||||
|
async def dashboard_customizations_asset(asset_path:str):
|
||||||
|
customization_root = os.path.join(
|
||||||
|
server.app.static_folder,
|
||||||
|
'customizations',
|
||||||
|
)
|
||||||
|
return await send_from_directory(customization_root, asset_path)
|
||||||
|
|
||||||
|
@blueprint.websocket('/dashboard/ws/games')
|
||||||
|
async def dashboard_games_ws():
|
||||||
|
ws_hub = server.dashboard_ws_hub
|
||||||
|
websocket_task = asyncio.current_task()
|
||||||
|
if websocket_task is not None:
|
||||||
|
await ws_hub.register_task(websocket_task)
|
||||||
|
|
||||||
|
subscriber_queue:asyncio.Queue[str] = asyncio.Queue(maxsize=20)
|
||||||
|
await ws_hub.register_subscriber(subscriber_queue)
|
||||||
|
try:
|
||||||
|
initial_payload = await server.dashboard_query.build_dashboard_games_event()
|
||||||
|
await asyncio.wait_for(
|
||||||
|
websocket.send(json.dumps(initial_payload)), timeout=1.5
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
queue_task = asyncio.create_task(subscriber_queue.get())
|
||||||
|
receive_task = asyncio.create_task(websocket.receive())
|
||||||
|
try:
|
||||||
|
done, _ = await asyncio.wait(
|
||||||
|
{queue_task, receive_task},
|
||||||
|
timeout=1.0,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(done) == 0:
|
||||||
|
if ws_hub.shutdown_event.is_set():
|
||||||
|
await asyncio.wait_for(
|
||||||
|
websocket.send(ws_hub.shutdown_message),
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
if receive_task in done:
|
||||||
|
try:
|
||||||
|
request_payload_raw = receive_task.result()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
response_event = await server.dashboard_query.handle_dashboard_ws_request(request_payload_raw)
|
||||||
|
if response_event is not None:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
websocket.send(json.dumps(response_event)),
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if queue_task in done:
|
||||||
|
event_payload = queue_task.result()
|
||||||
|
if event_payload == ws_hub.shutdown_message:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
websocket.send(event_payload), timeout=1.5
|
||||||
|
)
|
||||||
|
break
|
||||||
|
await asyncio.wait_for(
|
||||||
|
websocket.send(event_payload), timeout=1.5
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
if ws_hub.shutdown_event.is_set():
|
||||||
|
await asyncio.wait_for(
|
||||||
|
websocket.send(ws_hub.shutdown_message),
|
||||||
|
timeout=1.5,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
for pending_task in (queue_task, receive_task):
|
||||||
|
if not pending_task.done():
|
||||||
|
pending_task.cancel()
|
||||||
|
await asyncio.gather(
|
||||||
|
queue_task, receive_task, return_exceptions=True
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await ws_hub.unregister_subscriber(subscriber_queue)
|
||||||
|
if websocket_task is not None:
|
||||||
|
await ws_hub.unregister_task(websocket_task)
|
||||||
|
|
||||||
|
return blueprint
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.Server import Server
|
||||||
|
|
||||||
|
def create_metrics_blueprint(server:'Server') -> Blueprint:
|
||||||
|
blueprint = Blueprint('metrics', __name__)
|
||||||
|
|
||||||
|
@blueprint.get('/metrics')
|
||||||
|
async def metrics():
|
||||||
|
snapshot = await server.metrics_collector.build_snapshot(
|
||||||
|
server.game_runtime.game_last_seen_unix,
|
||||||
|
server.game_runtime.game_move_counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'prometheus' in (request.headers.get('User-Agent') or '').lower():
|
||||||
|
return (
|
||||||
|
server.metrics_collector.build_prometheus_metrics(snapshot),
|
||||||
|
200,
|
||||||
|
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(snapshot)
|
||||||
|
return blueprint
|
||||||
@@ -2,6 +2,7 @@ from typing import TypedDict
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from quart_common.web.env import env_bool, env_int
|
||||||
from server.Server import Server
|
from server.Server import Server
|
||||||
|
|
||||||
class RunConfig(TypedDict):
|
class RunConfig(TypedDict):
|
||||||
@@ -9,28 +10,26 @@ class RunConfig(TypedDict):
|
|||||||
port: int
|
port: int
|
||||||
debug: bool
|
debug: bool
|
||||||
|
|
||||||
def env_bool(name:str, default:bool=False) -> bool:
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.lower() in {'1', 'true', 'yes', 'on'}
|
|
||||||
|
|
||||||
def build_server_from_env(default_snake_type:str) -> Server:
|
def build_server_from_env(default_snake_type:str) -> Server:
|
||||||
data_path = str(Path(__file__).resolve().parent.parent)
|
data_path = str(Path(__file__).resolve().parent.parent)
|
||||||
game_state_backend = os.environ.get('GAME_STATE_BACKEND', 'memory')
|
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||||
game_state_redis_url = os.environ.get('GAME_STATE_REDIS_URL', 'redis://localhost:6379/0')
|
|
||||||
game_state_ttl_sec = int(os.environ.get('GAME_STATE_TTL_SEC', '900'))
|
|
||||||
|
|
||||||
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
||||||
if metrics_backend is None:
|
if metrics_backend is None:
|
||||||
metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory')
|
metrics_backend = os.environ.get('BACKEND', 'memory')
|
||||||
|
|
||||||
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', game_state_redis_url)
|
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url)
|
||||||
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
|
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
|
||||||
if metrics_ttl_sec_raw is None:
|
metrics_ttl_sec = env_int('METRICS_TTL_SEC', 900) if metrics_ttl_sec_raw is not None else None
|
||||||
metrics_ttl_sec = (game_state_ttl_sec if metrics_backend.strip().lower() == 'redis' else None)
|
|
||||||
else:
|
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
|
||||||
metrics_ttl_sec = int(metrics_ttl_sec_raw)
|
gameplay_db_backend = os.environ.get('GAMEPLAY_DB_BACKEND', 'sqlite')
|
||||||
|
gameplay_db_path = os.environ.get(
|
||||||
|
'GAMEPLAY_DB_PATH',
|
||||||
|
os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3'),
|
||||||
|
)
|
||||||
|
gameplay_db_busy_timeout_ms = env_int('GAMEPLAY_DB_BUSY_TIMEOUT_MS', 5000)
|
||||||
|
gameplay_db_pg_dsn = os.environ.get('GAMEPLAY_DB_PG_DSN', None)
|
||||||
|
|
||||||
server = Server(
|
server = Server(
|
||||||
data_path=data_path,
|
data_path=data_path,
|
||||||
@@ -38,13 +37,14 @@ def build_server_from_env(default_snake_type:str) -> Server:
|
|||||||
storage_type=os.environ.get('STORAGE', 'LocalStorage'),
|
storage_type=os.environ.get('STORAGE', 'LocalStorage'),
|
||||||
debug=env_bool('DEBUG_SERVER'),
|
debug=env_bool('DEBUG_SERVER'),
|
||||||
check_tls_security=False,
|
check_tls_security=False,
|
||||||
game_state_backend=game_state_backend,
|
|
||||||
game_state_redis_url=game_state_redis_url,
|
|
||||||
game_state_ttl_sec=game_state_ttl_sec,
|
|
||||||
game_state_local_cache=env_bool('GAME_STATE_LOCAL_CACHE', default=True),
|
|
||||||
metrics_backend=metrics_backend,
|
metrics_backend=metrics_backend,
|
||||||
metrics_redis_url=metrics_redis_url,
|
metrics_redis_url=metrics_redis_url,
|
||||||
metrics_ttl_sec=metrics_ttl_sec,
|
metrics_ttl_sec=metrics_ttl_sec,
|
||||||
|
gameplay_db_enabled=gameplay_db_enabled,
|
||||||
|
gameplay_db_backend=gameplay_db_backend,
|
||||||
|
gameplay_db_path=gameplay_db_path,
|
||||||
|
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||||
|
gameplay_db_pg_dsn=gameplay_db_pg_dsn,
|
||||||
)
|
)
|
||||||
|
|
||||||
if env_bool('STORE_GAME_HISTORY'):
|
if env_bool('STORE_GAME_HISTORY'):
|
||||||
@@ -55,6 +55,6 @@ def build_server_from_env(default_snake_type:str) -> Server:
|
|||||||
def build_run_config() -> RunConfig:
|
def build_run_config() -> RunConfig:
|
||||||
return {
|
return {
|
||||||
'host': os.environ.get('HOST', '0.0.0.0'),
|
'host': os.environ.get('HOST', '0.0.0.0'),
|
||||||
'port': int(os.environ.get('PORT', '8000')),
|
'port': env_int('PORT', 8000),
|
||||||
'debug': env_bool('DEBUG'),
|
'debug': env_bool('DEBUG'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from .backend.Template import GameplayBackendTemplate
|
||||||
|
|
||||||
|
class GameplayDatabase:
|
||||||
|
"""Thin facade that delegates all operations to a GameplayBackendTemplate.
|
||||||
|
|
||||||
|
Construct via GameplayBackendBuilder.build() or pass a backend directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, backend:GameplayBackendTemplate):
|
||||||
|
self._backend = backend
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
await self._backend.initialize()
|
||||||
|
|
||||||
|
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
||||||
|
await self._backend.record_game_start(game_state, snake_type, snake_version)
|
||||||
|
|
||||||
|
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
||||||
|
await self._backend.record_turn(game_state, my_move, my_thinking)
|
||||||
|
|
||||||
|
async def record_game_end(self, game_state:dict) -> None:
|
||||||
|
await self._backend.record_game_end(game_state)
|
||||||
|
|
||||||
|
async def get_summary(self, recent_limit:int=15) -> dict:
|
||||||
|
return await self._backend.get_summary(recent_limit)
|
||||||
|
|
||||||
|
async def list_games(self, limit:int=50) -> list[dict]:
|
||||||
|
return await self._backend.list_games(limit)
|
||||||
|
|
||||||
|
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
|
||||||
|
return await self._backend.finalize_stale_running_games(stale_after_seconds)
|
||||||
|
|
||||||
|
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||||
|
return await self._backend.get_game_replay(game_id)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._backend.close()
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
from server.dataset.Dataset import Dataset
|
from server.dataset.Dataset import Dataset
|
||||||
from server.GameBoard import GameBoard
|
|
||||||
from server.Files import save_file
|
from server.Files import save_file
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@@ -21,7 +25,7 @@ class LocalStorage:
|
|||||||
self.dataset_compress_rotated = os.getenv("DATASET_COMPRESS_ROTATED", "true").strip().lower() in ("1", "true", "yes", "on")
|
self.dataset_compress_rotated = os.getenv("DATASET_COMPRESS_ROTATED", "true").strip().lower() in ("1", "true", "yes", "on")
|
||||||
self.dataset_max_bytes = int(float(os.getenv("DATASET_JSONL_MAX_MB", "50")) * 1024 * 1024)
|
self.dataset_max_bytes = int(float(os.getenv("DATASET_JSONL_MAX_MB", "50")) * 1024 * 1024)
|
||||||
|
|
||||||
def _get_active_dataset_path(self, game_board:GameBoard):
|
def _get_active_dataset_path(self, game_board:'GameBoard'):
|
||||||
if not self.dataset_rotate_daily:
|
if not self.dataset_rotate_daily:
|
||||||
return self.dataset_jsonl_path
|
return self.dataset_jsonl_path
|
||||||
|
|
||||||
@@ -60,7 +64,7 @@ class LocalStorage:
|
|||||||
|
|
||||||
self._gzip_file(os.path.join(folder, name))
|
self._gzip_file(os.path.join(folder, name))
|
||||||
|
|
||||||
async def _rotate_if_needed(self, active_path:str, game_board:GameBoard):
|
async def _rotate_if_needed(self, active_path:str, game_board:'GameBoard'):
|
||||||
if self.dataset_max_bytes <= 0:
|
if self.dataset_max_bytes <= 0:
|
||||||
return
|
return
|
||||||
if not await aiofiles.os.path.exists(active_path):
|
if not await aiofiles.os.path.exists(active_path):
|
||||||
@@ -81,7 +85,7 @@ class LocalStorage:
|
|||||||
if self.dataset_compress_rotated:
|
if self.dataset_compress_rotated:
|
||||||
self._gzip_file(rotated_path)
|
self._gzip_file(rotated_path)
|
||||||
|
|
||||||
def _build_dataset_rows(self, dataset_payload:dict, game_board:GameBoard):
|
def _build_dataset_rows(self, dataset_payload:dict, game_board:'GameBoard'):
|
||||||
game_info = dataset_payload.get("game", {})
|
game_info = dataset_payload.get("game", {})
|
||||||
snake_info = dataset_payload.get("snake", {})
|
snake_info = dataset_payload.get("snake", {})
|
||||||
|
|
||||||
@@ -102,7 +106,7 @@ class LocalStorage:
|
|||||||
})
|
})
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
async def _append_dataset_jsonl(self, dataset_payload:dict, game_board:GameBoard):
|
async def _append_dataset_jsonl(self, dataset_payload:dict, game_board:'GameBoard'):
|
||||||
rows = self._build_dataset_rows(dataset_payload, game_board)
|
rows = self._build_dataset_rows(dataset_payload, game_board)
|
||||||
if len(rows) == 0:
|
if len(rows) == 0:
|
||||||
return
|
return
|
||||||
@@ -116,7 +120,7 @@ class LocalStorage:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
await f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
await f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
def _get_correct_folder_for_save_file(self, game_board:GameBoard, file_name:str, game_type:str, leader_board:bool, winner:bool):
|
def _get_correct_folder_for_save_file(self, game_board:'GameBoard', file_name:str, game_type:str, leader_board:bool, winner:bool):
|
||||||
storage_folder = self.file_path
|
storage_folder = self.file_path
|
||||||
if leader_board:
|
if leader_board:
|
||||||
storage_folder = os.path.join(storage_folder, "00_Leaderboards")
|
storage_folder = os.path.join(storage_folder, "00_Leaderboards")
|
||||||
@@ -136,7 +140,7 @@ class LocalStorage:
|
|||||||
|
|
||||||
return os.path.join(storage_folder, file_name)
|
return os.path.join(storage_folder, file_name)
|
||||||
|
|
||||||
async def save(self, game_board:GameBoard):
|
async def save(self, game_board:'GameBoard'):
|
||||||
game_type = game_board.get_type_of_game()
|
game_type = game_board.get_type_of_game()
|
||||||
dataset = Dataset(game_board).build(only_good_moves=True)
|
dataset = Dataset(game_board).build(only_good_moves=True)
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from .GameplayDatabase import GameplayDatabase
|
||||||
|
from .backend import GameplayBackendBuilder
|
||||||
|
|
||||||
|
from .LocalStorage import LocalStorage
|
||||||
|
from .EdgeDB import EdgeDB
|
||||||
|
|
||||||
|
class StorageLoader:
|
||||||
|
@classmethod
|
||||||
|
def build(self, selected_storage:str) -> LocalStorage|EdgeDB:
|
||||||
|
storage_module = __import__(f"server.database.{selected_storage}", fromlist=[selected_storage])
|
||||||
|
storage_class = getattr(storage_module, selected_storage)
|
||||||
|
return storage_class
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
"""PostgreSQL gameplay backend using asyncpg.
|
||||||
|
|
||||||
|
JSON columns use the JSONB type so PostgreSQL stores them in a binary,
|
||||||
|
decomposed format and automatically compresses large values via TOAST
|
||||||
|
(Oversized-Attribute Storage Technique). No application-level
|
||||||
|
serialisation/deserialisation round-trip is needed for reads — asyncpg
|
||||||
|
decodes JSONB rows directly into Python dicts/lists.
|
||||||
|
|
||||||
|
Connection: pass a DSN via the `dsn` constructor argument, e.g.
|
||||||
|
postgresql://user:password@host:5432/dbname
|
||||||
|
|
||||||
|
or set GAMEPLAY_DB_PG_DSN in the environment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio, json, logging, sqlite3, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
from .Template import GameplayBackendTemplate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
if not logger.handlers:
|
||||||
|
_handler = logging.StreamHandler(stream=sys.stdout)
|
||||||
|
_handler.setFormatter(logging.Formatter(fmt="%(levelname)s %(module)s: %(message)s"))
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
# DDL --------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS games (
|
||||||
|
game_id TEXT PRIMARY KEY,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ended_at TIMESTAMPTZ,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
source TEXT,
|
||||||
|
map_name TEXT,
|
||||||
|
ruleset_name TEXT,
|
||||||
|
ruleset_version TEXT,
|
||||||
|
your_snake_id TEXT,
|
||||||
|
your_snake_name TEXT,
|
||||||
|
your_snake_type TEXT,
|
||||||
|
your_snake_version TEXT,
|
||||||
|
game_type TEXT,
|
||||||
|
winner_name TEXT,
|
||||||
|
winner_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
final_turn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turns (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||||
|
turn INTEGER NOT NULL,
|
||||||
|
observed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
my_move TEXT,
|
||||||
|
my_thinking JSONB,
|
||||||
|
board_state JSONB NOT NULL,
|
||||||
|
snakes JSONB NOT NULL,
|
||||||
|
you JSONB NOT NULL,
|
||||||
|
food JSONB NOT NULL,
|
||||||
|
hazards JSONB NOT NULL,
|
||||||
|
UNIQUE (game_id, turn)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snake_turns (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
game_id TEXT NOT NULL REFERENCES games(game_id) ON DELETE CASCADE,
|
||||||
|
turn INTEGER NOT NULL,
|
||||||
|
snake_id TEXT NOT NULL,
|
||||||
|
snake_name TEXT,
|
||||||
|
health INTEGER,
|
||||||
|
length INTEGER,
|
||||||
|
head_x INTEGER,
|
||||||
|
head_y INTEGER,
|
||||||
|
body JSONB NOT NULL,
|
||||||
|
is_you BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
inferred_move TEXT,
|
||||||
|
latency TEXT,
|
||||||
|
UNIQUE (game_id, turn, snake_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_turns_game_turn ON turns(game_id, turn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_snake_turns_game_turn ON snake_turns(game_id, turn);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Schema evolution: add new columns to existing tables (idempotent).
|
||||||
|
_ALTER_DDL = """
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS game_type TEXT;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_type TEXT;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS your_snake_version TEXT;
|
||||||
|
ALTER TABLE games ADD COLUMN IF NOT EXISTS winner_name TEXT;
|
||||||
|
ALTER TABLE turns ADD COLUMN IF NOT EXISTS my_thinking JSONB;
|
||||||
|
ALTER TABLE snake_turns ADD COLUMN IF NOT EXISTS latency TEXT;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Force TOAST compression on the large JSONB columns so that even
|
||||||
|
# moderately-sized payloads get compressed on-disk.
|
||||||
|
_TOAST_DDL = """
|
||||||
|
ALTER TABLE turns ALTER COLUMN board_state SET STORAGE EXTENDED;
|
||||||
|
ALTER TABLE turns ALTER COLUMN snakes SET STORAGE EXTENDED;
|
||||||
|
ALTER TABLE turns ALTER COLUMN you SET STORAGE EXTENDED;
|
||||||
|
ALTER TABLE turns ALTER COLUMN food SET STORAGE EXTENDED;
|
||||||
|
ALTER TABLE turns ALTER COLUMN hazards SET STORAGE EXTENDED;
|
||||||
|
ALTER TABLE snake_turns ALTER COLUMN body SET STORAGE EXTENDED;
|
||||||
|
"""
|
||||||
|
|
||||||
|
class PostgresqlGameplayBackend(GameplayBackendTemplate):
|
||||||
|
"""Async PostgreSQL backend. A connection pool is created lazily on the
|
||||||
|
first method call and reused for the lifetime of the object.
|
||||||
|
|
||||||
|
Requires: pip install asyncpg
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dsn:str, min_size:int=1, max_size:int=5, sqlite_migration_path:str|None=None):
|
||||||
|
self._dsn = dsn
|
||||||
|
self._min_size = min_size
|
||||||
|
self._max_size = max_size
|
||||||
|
self._sqlite_migration_path = sqlite_migration_path
|
||||||
|
self._pool = None # asyncpg.Pool, typed at runtime
|
||||||
|
|
||||||
|
# ── DSN normalisation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DEFAULT_DB_NAME = "battlesnake"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _ensure_db_name(cls, dsn:str) -> str:
|
||||||
|
"""Return *dsn* with a database name appended when none is present.
|
||||||
|
|
||||||
|
A DSN has no database name when its path component is empty or ``/``.
|
||||||
|
In that case ``battlesnake`` is appended so asyncpg gets a complete
|
||||||
|
connection string without the caller having to remember to add one.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(dsn)
|
||||||
|
db = parsed.path.lstrip("/")
|
||||||
|
if db:
|
||||||
|
return dsn
|
||||||
|
|
||||||
|
new_path = f"/{cls._DEFAULT_DB_NAME}"
|
||||||
|
return urlunparse(parsed._replace(path=new_path))
|
||||||
|
|
||||||
|
# ── pool / schema ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Eagerly create the connection pool on startup so schema init and
|
||||||
|
SQLite migration run immediately rather than on the first game request."""
|
||||||
|
await self._get_pool()
|
||||||
|
|
||||||
|
async def _get_pool(self):
|
||||||
|
if self._pool is None:
|
||||||
|
try:
|
||||||
|
import asyncpg # noqa: PLC0415
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"asyncpg is required for the PostgreSQL gameplay backend. "
|
||||||
|
"Install it with: pip install asyncpg"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
target_dsn = self._ensure_db_name(self._dsn)
|
||||||
|
await self._ensure_database_exists(asyncpg, target_dsn)
|
||||||
|
|
||||||
|
async def _init_conn(conn) -> None:
|
||||||
|
await conn.set_type_codec('jsonb', encoder=json.dumps, decoder=json.loads, schema='pg_catalog')
|
||||||
|
await conn.set_type_codec('json', encoder=json.dumps, decoder=json.loads, schema='pg_catalog')
|
||||||
|
|
||||||
|
self._pool = await asyncpg.create_pool(
|
||||||
|
dsn=target_dsn,
|
||||||
|
min_size=self._min_size,
|
||||||
|
max_size=self._max_size,
|
||||||
|
init=_init_conn,
|
||||||
|
)
|
||||||
|
await self._initialize_schema()
|
||||||
|
await self._maybe_migrate_from_sqlite()
|
||||||
|
return self._pool
|
||||||
|
|
||||||
|
async def _ensure_database_exists(self, asyncpg, target_dsn:str) -> None:
|
||||||
|
"""Connect to the postgres maintenance DB and CREATE the target database
|
||||||
|
if it does not already exist. Uses a plain connection (not a pool) so
|
||||||
|
the CREATE DATABASE statement can run outside any transaction."""
|
||||||
|
parsed = urlparse(target_dsn)
|
||||||
|
db_name = parsed.path.lstrip("/")
|
||||||
|
maintenance_dsn = urlunparse(parsed._replace(path="/postgres"))
|
||||||
|
try:
|
||||||
|
conn = await asyncpg.connect(dsn=maintenance_dsn)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to connecting without specifying a database — some setups
|
||||||
|
# (e.g. Cloud SQL, managed PG) disallow direct access to 'postgres'.
|
||||||
|
maintenance_dsn = urlunparse(parsed._replace(path=""))
|
||||||
|
conn = await asyncpg.connect(dsn=maintenance_dsn)
|
||||||
|
try:
|
||||||
|
exists = await conn.fetchval(
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = $1", db_name
|
||||||
|
)
|
||||||
|
if not exists:
|
||||||
|
await conn.execute(f'CREATE DATABASE "{db_name}"')
|
||||||
|
logger.info(f"PostgreSQL: created database '{db_name}'")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
async def _initialize_schema(self) -> None:
|
||||||
|
assert self._pool is not None
|
||||||
|
async with self._pool.acquire() as conn:
|
||||||
|
await conn.execute(_DDL)
|
||||||
|
await conn.execute(_ALTER_DDL)
|
||||||
|
# TOAST storage hints are idempotent; ignore errors on repeated runs.
|
||||||
|
try:
|
||||||
|
await conn.execute(_TOAST_DDL)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"TOAST DDL skipped (likely already set): {exc}")
|
||||||
|
|
||||||
|
# ── sqlite migration ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _maybe_migrate_from_sqlite(self) -> None:
|
||||||
|
if not self._sqlite_migration_path:
|
||||||
|
return
|
||||||
|
|
||||||
|
src = Path(self._sqlite_migration_path)
|
||||||
|
if not src.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"SQLite migration: found {src}, starting migration to PostgreSQL …")
|
||||||
|
try:
|
||||||
|
games, turns, snake_turns = await asyncio.to_thread(self._read_sqlite_data_sync, str(src))
|
||||||
|
await self._insert_migrated_data(games, turns, snake_turns)
|
||||||
|
|
||||||
|
done_path = src.with_suffix(".migrated")
|
||||||
|
src.rename(done_path)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"SQLite migration complete: {len(games)} games, {len(turns)} turns, "
|
||||||
|
f"{len(snake_turns)} snake_turns migrated. "
|
||||||
|
f"Source file renamed to {done_path.name}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("SQLite migration failed — PostgreSQL data is untouched, original SQLite file kept")
|
||||||
|
|
||||||
|
def _read_sqlite_data_sync(self, db_path:str) -> tuple[list[sqlite3.Row], list[sqlite3.Row], list[sqlite3.Row]]:
|
||||||
|
conn = sqlite3.connect(db_path, timeout=30, isolation_level=None)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
try:
|
||||||
|
games = conn.execute("""
|
||||||
|
SELECT game_id, started_at, ended_at, width, height, source, map_name,
|
||||||
|
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||||
|
your_snake_type, your_snake_version, game_type,
|
||||||
|
winner_names_json, winner_you, final_turn, status
|
||||||
|
FROM games
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
""").fetchall()
|
||||||
|
turns = conn.execute("""
|
||||||
|
SELECT game_id, turn, observed_at, my_move, my_thinking_json,
|
||||||
|
board_state_json, snakes_json, you_json, food_json, hazards_json
|
||||||
|
FROM turns
|
||||||
|
ORDER BY game_id ASC, turn ASC
|
||||||
|
""").fetchall()
|
||||||
|
snake_turns = conn.execute("""
|
||||||
|
SELECT game_id, turn, snake_id, snake_name, health, length,
|
||||||
|
head_x, head_y, body_json, is_you, inferred_move, latency
|
||||||
|
FROM snake_turns
|
||||||
|
ORDER BY game_id ASC, turn ASC, snake_id ASC
|
||||||
|
""").fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return games, turns, snake_turns
|
||||||
|
|
||||||
|
def _parse_ts(self, value:str|None) -> datetime|None:
|
||||||
|
"""Parse an ISO-8601 TEXT timestamp from SQLite into a timezone-aware datetime."""
|
||||||
|
ts = self._parse_utc_timestamp(value)
|
||||||
|
return ts # already UTC-aware from base class helper
|
||||||
|
|
||||||
|
def _parse_json(self, value: str|None) -> object:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _insert_migrated_data(self, games:list, turns:list, snake_turns:list) -> None:
|
||||||
|
assert self._pool is not None
|
||||||
|
async with self._pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
# games ─────────────────────────────────────────────────────────────
|
||||||
|
# winner_name is TEXT — no cast needed.
|
||||||
|
await conn.executemany("""
|
||||||
|
INSERT INTO games (
|
||||||
|
game_id, started_at, ended_at, width, height, source, map_name,
|
||||||
|
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||||
|
your_snake_type, your_snake_version, game_type,
|
||||||
|
winner_name, winner_you, final_turn, status
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
|
||||||
|
ON CONFLICT (game_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
row["game_id"],
|
||||||
|
self._parse_ts(row["started_at"]),
|
||||||
|
self._parse_ts(row["ended_at"]),
|
||||||
|
row["width"],
|
||||||
|
row["height"],
|
||||||
|
row["source"],
|
||||||
|
row["map_name"],
|
||||||
|
row["ruleset_name"],
|
||||||
|
row["ruleset_version"],
|
||||||
|
row["your_snake_id"],
|
||||||
|
row["your_snake_name"],
|
||||||
|
row["your_snake_type"],
|
||||||
|
row["your_snake_version"],
|
||||||
|
row["game_type"],
|
||||||
|
(self._parse_json(row["winner_names_json"]) or [None])[0],
|
||||||
|
bool(row["winner_you"]),
|
||||||
|
row["final_turn"],
|
||||||
|
row["status"],
|
||||||
|
)
|
||||||
|
for row in games
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.executemany("""
|
||||||
|
INSERT INTO turns (
|
||||||
|
game_id, turn, observed_at, my_move, my_thinking,
|
||||||
|
board_state, snakes, you, food, hazards
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (game_id, turn) DO NOTHING
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
row["game_id"],
|
||||||
|
row["turn"],
|
||||||
|
self._parse_ts(row["observed_at"]),
|
||||||
|
row["my_move"],
|
||||||
|
self._parse_json(row["my_thinking_json"]),
|
||||||
|
self._parse_json(row["board_state_json"]),
|
||||||
|
self._parse_json(row["snakes_json"]),
|
||||||
|
self._parse_json(row["you_json"]),
|
||||||
|
self._parse_json(row["food_json"]),
|
||||||
|
self._parse_json(row["hazards_json"]),
|
||||||
|
)
|
||||||
|
for row in turns
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# snake_turns
|
||||||
|
await conn.executemany("""
|
||||||
|
INSERT INTO snake_turns (
|
||||||
|
game_id, turn, snake_id, snake_name, health, length,
|
||||||
|
head_x, head_y, body, is_you, inferred_move, latency
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
ON CONFLICT (game_id, turn, snake_id) DO NOTHING
|
||||||
|
""",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
row["game_id"],
|
||||||
|
row["turn"],
|
||||||
|
row["snake_id"],
|
||||||
|
row["snake_name"],
|
||||||
|
row["health"],
|
||||||
|
row["length"],
|
||||||
|
row["head_x"],
|
||||||
|
row["head_y"],
|
||||||
|
self._parse_json(row["body_json"]),
|
||||||
|
bool(row["is_you"]),
|
||||||
|
row["inferred_move"],
|
||||||
|
row["latency"],
|
||||||
|
)
|
||||||
|
for row in snake_turns
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _utc_now_ts(self) -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# The pool init callback registers JSON/JSONB codecs so asyncpg automatically
|
||||||
|
# encodes Python dicts/lists on write and decodes them on read.
|
||||||
|
|
||||||
|
# ── write methods ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
||||||
|
game = game_state.get("game", {})
|
||||||
|
board = game_state.get("board", {})
|
||||||
|
you = self._extract_you(game_state)
|
||||||
|
ruleset = game.get("ruleset", {})
|
||||||
|
game_type = self._derive_game_type(board, ruleset)
|
||||||
|
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO games (
|
||||||
|
game_id, started_at, width, height, source, map_name,
|
||||||
|
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||||
|
your_snake_type, your_snake_version, game_type, status
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'running')
|
||||||
|
ON CONFLICT (game_id) DO UPDATE SET
|
||||||
|
width = EXCLUDED.width,
|
||||||
|
height = EXCLUDED.height,
|
||||||
|
source = EXCLUDED.source,
|
||||||
|
map_name = EXCLUDED.map_name,
|
||||||
|
ruleset_name = EXCLUDED.ruleset_name,
|
||||||
|
ruleset_version = EXCLUDED.ruleset_version,
|
||||||
|
your_snake_id = EXCLUDED.your_snake_id,
|
||||||
|
your_snake_name = EXCLUDED.your_snake_name,
|
||||||
|
your_snake_type = EXCLUDED.your_snake_type,
|
||||||
|
your_snake_version = EXCLUDED.your_snake_version,
|
||||||
|
game_type = EXCLUDED.game_type,
|
||||||
|
status = 'running'
|
||||||
|
""",
|
||||||
|
game.get("id"),
|
||||||
|
self._utc_now_ts(),
|
||||||
|
board.get("width"),
|
||||||
|
board.get("height"),
|
||||||
|
game.get("source"),
|
||||||
|
game.get("map"),
|
||||||
|
ruleset.get("name"),
|
||||||
|
ruleset.get("version"),
|
||||||
|
you.get("id"),
|
||||||
|
you.get("name"),
|
||||||
|
snake_type,
|
||||||
|
snake_version,
|
||||||
|
game_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
||||||
|
game = game_state.get("game", {})
|
||||||
|
board = game_state.get("board", {})
|
||||||
|
snakes = self._extract_snakes(game_state)
|
||||||
|
you = self._extract_you(game_state)
|
||||||
|
game_id = game.get("id")
|
||||||
|
turn = int(game_state.get("turn", 0))
|
||||||
|
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO turns (
|
||||||
|
game_id, turn, observed_at, my_move, my_thinking,
|
||||||
|
board_state, snakes, you, food, hazards
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||||
|
ON CONFLICT (game_id, turn) DO UPDATE SET
|
||||||
|
observed_at = EXCLUDED.observed_at,
|
||||||
|
my_move = EXCLUDED.my_move,
|
||||||
|
my_thinking = EXCLUDED.my_thinking,
|
||||||
|
board_state = EXCLUDED.board_state,
|
||||||
|
snakes = EXCLUDED.snakes,
|
||||||
|
you = EXCLUDED.you,
|
||||||
|
food = EXCLUDED.food,
|
||||||
|
hazards = EXCLUDED.hazards
|
||||||
|
""",
|
||||||
|
game_id,
|
||||||
|
turn,
|
||||||
|
self._utc_now_ts(),
|
||||||
|
my_move,
|
||||||
|
my_thinking,
|
||||||
|
board,
|
||||||
|
snakes,
|
||||||
|
you,
|
||||||
|
board.get("food", []),
|
||||||
|
board.get("hazards", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_positions:dict[str, tuple[int, int]] = {}
|
||||||
|
if turn > 0:
|
||||||
|
prev_rows = await conn.fetch("""
|
||||||
|
SELECT snake_id, head_x, head_y
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = $1 AND turn = $2
|
||||||
|
""",
|
||||||
|
game_id, turn - 1,
|
||||||
|
)
|
||||||
|
previous_positions = {
|
||||||
|
row["snake_id"]: (int(row["head_x"]), int(row["head_y"]))
|
||||||
|
for row in prev_rows
|
||||||
|
if row["head_x"] is not None and row["head_y"] is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
you_id = you.get("id")
|
||||||
|
for snake in snakes:
|
||||||
|
snake_id = snake.get("id")
|
||||||
|
head = snake.get("head", {})
|
||||||
|
head_x = head.get("x")
|
||||||
|
head_y = head.get("y")
|
||||||
|
if snake_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_head = (
|
||||||
|
(int(head_x), int(head_y))
|
||||||
|
if head_x is not None and head_y is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
inferred = self._infer_direction(previous_positions.get(snake_id), new_head)
|
||||||
|
await conn.execute("""
|
||||||
|
INSERT INTO snake_turns (
|
||||||
|
game_id, turn, snake_id, snake_name, health, length,
|
||||||
|
head_x, head_y, body, is_you, inferred_move, latency
|
||||||
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
ON CONFLICT (game_id, turn, snake_id) DO UPDATE SET
|
||||||
|
snake_name = EXCLUDED.snake_name,
|
||||||
|
health = EXCLUDED.health,
|
||||||
|
length = EXCLUDED.length,
|
||||||
|
head_x = EXCLUDED.head_x,
|
||||||
|
head_y = EXCLUDED.head_y,
|
||||||
|
body = EXCLUDED.body,
|
||||||
|
is_you = EXCLUDED.is_you,
|
||||||
|
inferred_move = EXCLUDED.inferred_move,
|
||||||
|
latency = EXCLUDED.latency
|
||||||
|
""",
|
||||||
|
game_id,
|
||||||
|
turn,
|
||||||
|
snake_id,
|
||||||
|
snake.get("name"),
|
||||||
|
snake.get("health"),
|
||||||
|
snake.get("length"),
|
||||||
|
head_x,
|
||||||
|
head_y,
|
||||||
|
snake.get("body", []),
|
||||||
|
snake_id == you_id,
|
||||||
|
inferred,
|
||||||
|
snake.get("latency"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE games
|
||||||
|
SET final_turn = GREATEST(final_turn, $1)
|
||||||
|
WHERE game_id = $2
|
||||||
|
""",
|
||||||
|
turn, game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def record_game_end(self, game_state:dict) -> None:
|
||||||
|
game = game_state.get("game", {})
|
||||||
|
game_id = game.get("id")
|
||||||
|
board = game_state.get("board", {})
|
||||||
|
snakes = list(board.get("snakes", []))
|
||||||
|
you = self._extract_you(game_state)
|
||||||
|
winner_name = next((s.get("name") for s in snakes if s.get("name")), None)
|
||||||
|
you_id = you.get("id")
|
||||||
|
winner_you = any(s.get("id") == you_id for s in snakes)
|
||||||
|
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE games
|
||||||
|
SET ended_at = $1,
|
||||||
|
winner_name = $2,
|
||||||
|
winner_you = $3,
|
||||||
|
final_turn = GREATEST(final_turn, $4),
|
||||||
|
status = 'finished'
|
||||||
|
WHERE game_id = $5
|
||||||
|
""",
|
||||||
|
self._utc_now_ts(),
|
||||||
|
winner_name,
|
||||||
|
winner_you,
|
||||||
|
int(game_state.get("turn", 0)),
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── stale game finalization ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
|
||||||
|
threshold = max(60, int(stale_after_seconds))
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
finalized = 0
|
||||||
|
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT game_id, started_at, final_turn, your_snake_id
|
||||||
|
FROM games
|
||||||
|
WHERE status = 'running'
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
""")
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
started_at = row["started_at"]
|
||||||
|
if started_at is None:
|
||||||
|
continue
|
||||||
|
if started_at.tzinfo is None:
|
||||||
|
started_at = started_at.replace(tzinfo=timezone.utc)
|
||||||
|
if (now_utc - started_at).total_seconds() < threshold:
|
||||||
|
continue
|
||||||
|
|
||||||
|
game_id = row["game_id"]
|
||||||
|
your_snake_id = row["your_snake_id"]
|
||||||
|
final_turn = int(row["final_turn"] or 0)
|
||||||
|
|
||||||
|
snake_rows = await conn.fetch("""
|
||||||
|
SELECT snake_id, snake_name
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = $1 AND turn = $2
|
||||||
|
ORDER BY is_you DESC, snake_name ASC
|
||||||
|
""",
|
||||||
|
game_id, final_turn,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(snake_rows) == 0:
|
||||||
|
latest_row = await conn.fetchrow(
|
||||||
|
"SELECT MAX(turn) AS latest_turn FROM snake_turns WHERE game_id = $1",
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest_row is not None and latest_row["latest_turn"] is not None:
|
||||||
|
final_turn = int(latest_row["latest_turn"])
|
||||||
|
snake_rows = await conn.fetch("""
|
||||||
|
SELECT snake_id, snake_name
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = $1 AND turn = $2
|
||||||
|
ORDER BY is_you DESC, snake_name ASC
|
||||||
|
""",
|
||||||
|
game_id, final_turn,
|
||||||
|
)
|
||||||
|
|
||||||
|
survivor_ids = [s["snake_id"] for s in snake_rows if s["snake_id"]]
|
||||||
|
winner_you = bool(
|
||||||
|
your_snake_id
|
||||||
|
and your_snake_id in survivor_ids
|
||||||
|
and len(survivor_ids) == 1
|
||||||
|
)
|
||||||
|
survivor_name = next((s["snake_name"] for s in snake_rows if s["snake_name"]), None)
|
||||||
|
|
||||||
|
tag = await conn.execute("""
|
||||||
|
UPDATE games
|
||||||
|
SET ended_at = $1,
|
||||||
|
winner_name = $2,
|
||||||
|
winner_you = $3,
|
||||||
|
final_turn = GREATEST(final_turn, $4),
|
||||||
|
status = 'finished'
|
||||||
|
WHERE game_id = $5 AND status = 'running'
|
||||||
|
""",
|
||||||
|
self._utc_now_ts(),
|
||||||
|
survivor_name,
|
||||||
|
winner_you,
|
||||||
|
final_turn,
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
if tag and tag.endswith("1"):
|
||||||
|
finalized += 1
|
||||||
|
|
||||||
|
return finalized
|
||||||
|
|
||||||
|
# ── read methods ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_summary(self, recent_limit:int=15) -> dict:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
totals = await conn.fetchrow("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_games,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'running') AS running_games,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'finished') AS finished_games,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'finished' AND winner_you) AS wins,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'finished' AND NOT winner_you) AS losses,
|
||||||
|
AVG(final_turn) FILTER (WHERE status = 'finished') AS avg_turns
|
||||||
|
FROM games
|
||||||
|
""")
|
||||||
|
|
||||||
|
by_type = await conn.fetch("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(game_type, ruleset_name, 'unknown') AS type_label,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
COUNT(*) FILTER (WHERE winner_you) AS wins,
|
||||||
|
COUNT(*) FILTER (WHERE NOT winner_you) AS losses
|
||||||
|
FROM games
|
||||||
|
WHERE status = 'finished'
|
||||||
|
GROUP BY type_label
|
||||||
|
ORDER BY total DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
recent = await conn.fetch("""
|
||||||
|
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
|
||||||
|
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
|
||||||
|
FROM games
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
""",
|
||||||
|
max(1, int(recent_limit)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_games": int(totals["total_games"] or 0),
|
||||||
|
"running_games": int(totals["running_games"] or 0),
|
||||||
|
"finished_games": int(totals["finished_games"] or 0),
|
||||||
|
"wins": int(totals["wins"] or 0),
|
||||||
|
"losses": int(totals["losses"] or 0),
|
||||||
|
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
|
||||||
|
"by_game_type": [{
|
||||||
|
"game_type": row["type_label"],
|
||||||
|
"total": int(row["total"]),
|
||||||
|
"wins": int(row["wins"]),
|
||||||
|
"losses": int(row["losses"]),
|
||||||
|
} for row in by_type],
|
||||||
|
"recent_games": [{
|
||||||
|
"game_id": row["game_id"],
|
||||||
|
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
|
||||||
|
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
|
||||||
|
"map": row["map_name"],
|
||||||
|
"ruleset": row["ruleset_name"],
|
||||||
|
"game_type": row["game_type"],
|
||||||
|
"snake": row["your_snake_name"],
|
||||||
|
"snake_type": row["your_snake_type"],
|
||||||
|
"snake_version": row["your_snake_version"],
|
||||||
|
"winner_you": bool(row["winner_you"]),
|
||||||
|
"final_turn": int(row["final_turn"] or 0),
|
||||||
|
"status": row["status"],
|
||||||
|
} for row in recent],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_games(self, limit:int=50) -> list[dict]:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch("""
|
||||||
|
SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, game_type,
|
||||||
|
your_snake_name, your_snake_type, your_snake_version,
|
||||||
|
winner_you, winner_name, final_turn, status
|
||||||
|
FROM games
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT $1
|
||||||
|
""",
|
||||||
|
max(1, int(limit)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"game_id": row["game_id"],
|
||||||
|
"started_at": row["started_at"].isoformat() if row["started_at"] else None,
|
||||||
|
"ended_at": row["ended_at"].isoformat() if row["ended_at"] else None,
|
||||||
|
"map": row["map_name"],
|
||||||
|
"source": row["source"],
|
||||||
|
"ruleset": row["ruleset_name"],
|
||||||
|
"game_type": row["game_type"],
|
||||||
|
"snake": row["your_snake_name"],
|
||||||
|
"snake_type": row["your_snake_type"],
|
||||||
|
"snake_version": row["your_snake_version"],
|
||||||
|
"winner_you": bool(row["winner_you"]),
|
||||||
|
"winner_name": row["winner_name"],
|
||||||
|
"final_turn": int(row["final_turn"] or 0),
|
||||||
|
"status": row["status"],
|
||||||
|
} for row in rows]
|
||||||
|
|
||||||
|
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||||
|
pool = await self._get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
game_row = await conn.fetchrow("""
|
||||||
|
SELECT game_id, started_at, ended_at, width, height, source, map_name,
|
||||||
|
ruleset_name, ruleset_version, game_type, your_snake_id, your_snake_name,
|
||||||
|
your_snake_type, your_snake_version,
|
||||||
|
winner_name, winner_you, final_turn, status
|
||||||
|
FROM games
|
||||||
|
WHERE game_id = $1
|
||||||
|
""",
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if game_row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
turn_rows = await conn.fetch("""
|
||||||
|
SELECT turn, observed_at, my_move, my_thinking,
|
||||||
|
board_state, food, hazards, you
|
||||||
|
FROM turns
|
||||||
|
WHERE game_id = $1
|
||||||
|
ORDER BY turn ASC
|
||||||
|
""",
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
snake_rows = await conn.fetch("""
|
||||||
|
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
||||||
|
body, is_you, inferred_move, latency
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = $1
|
||||||
|
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
||||||
|
""",
|
||||||
|
game_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
snakes_by_turn:dict[int, list[dict]] = {}
|
||||||
|
for row in snake_rows:
|
||||||
|
snakes_by_turn.setdefault(int(row["turn"]), []).append({
|
||||||
|
"snake_id": row["snake_id"],
|
||||||
|
"snake_name": row["snake_name"],
|
||||||
|
"health": row["health"],
|
||||||
|
"length": row["length"],
|
||||||
|
"head": {"x": row["head_x"], "y": row["head_y"]},
|
||||||
|
"body": row["body"] or [],
|
||||||
|
"is_you": bool(row["is_you"]),
|
||||||
|
"inferred_move": row["inferred_move"],
|
||||||
|
"latency": row["latency"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"game": {
|
||||||
|
"game_id": game_row["game_id"],
|
||||||
|
"started_at": game_row["started_at"].isoformat() if game_row["started_at"] else None,
|
||||||
|
"ended_at": game_row["ended_at"].isoformat() if game_row["ended_at"] else None,
|
||||||
|
"width": game_row["width"],
|
||||||
|
"height": game_row["height"],
|
||||||
|
"source": game_row["source"],
|
||||||
|
"map": game_row["map_name"],
|
||||||
|
"ruleset_name": game_row["ruleset_name"],
|
||||||
|
"ruleset_version": game_row["ruleset_version"],
|
||||||
|
"game_type": game_row["game_type"],
|
||||||
|
"your_snake_id": game_row["your_snake_id"],
|
||||||
|
"your_snake_name": game_row["your_snake_name"],
|
||||||
|
"your_snake_type": game_row["your_snake_type"],
|
||||||
|
"your_snake_version": game_row["your_snake_version"],
|
||||||
|
"winner_name": game_row["winner_name"],
|
||||||
|
"winner_you": bool(game_row["winner_you"]),
|
||||||
|
"final_turn": int(game_row["final_turn"] or 0),
|
||||||
|
"status": game_row["status"],
|
||||||
|
},
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"turn": int(row["turn"]),
|
||||||
|
"observed_at": row["observed_at"].isoformat() if row["observed_at"] else None,
|
||||||
|
"my_move": row["my_move"],
|
||||||
|
"my_thinking": row["my_thinking"],
|
||||||
|
"board": row["board_state"],
|
||||||
|
"food": row["food"] or [],
|
||||||
|
"hazards": row["hazards"] or [],
|
||||||
|
"you": row["you"] or {},
|
||||||
|
"snakes": snakes_by_turn.get(int(row["turn"]), []),
|
||||||
|
}
|
||||||
|
for row in turn_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── lifecycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._pool is not None:
|
||||||
|
await self._pool.close()
|
||||||
|
self._pool = None
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
from quart_common.web.env import env_bool
|
||||||
|
|
||||||
|
import asyncio, sqlite3, json, os, logging, sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.database.backend.Template import GameplayBackendTemplate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
if not logger.handlers:
|
||||||
|
_handler = logging.StreamHandler(stream=sys.stdout)
|
||||||
|
_handler.setFormatter(logging.Formatter(fmt="%(levelname)s %(module)s: %(message)s"))
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
_ZSTD_EXT = Path(os.environ.get("SQLITE_ZSTD_EXT", "/usr/local/lib/libsqlite_zstd.so")).expanduser().resolve()
|
||||||
|
|
||||||
|
class SqliteGameplayBackend(GameplayBackendTemplate):
|
||||||
|
def __init__(self, db_path:str, busy_timeout_ms:int=5000):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.busy_timeout_ms = max(1000, int(busy_timeout_ms))
|
||||||
|
self._zstd_available = False
|
||||||
|
self._initialize_database()
|
||||||
|
|
||||||
|
# ── connection ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(
|
||||||
|
self.db_path,
|
||||||
|
timeout=max(1, self.busy_timeout_ms // 1000),
|
||||||
|
isolation_level=None,
|
||||||
|
)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if _ZSTD_EXT.exists() and not env_bool('DISABLE_GAMEPLAY_DB_COMPRESSION', True):
|
||||||
|
try:
|
||||||
|
connection.enable_load_extension(True)
|
||||||
|
connection.load_extension(str(_ZSTD_EXT))
|
||||||
|
self._zstd_available = True
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
logger.warning(f"sqlite-zstd extension skipped: {e}")
|
||||||
|
finally:
|
||||||
|
connection.enable_load_extension(False)
|
||||||
|
|
||||||
|
connection.execute("PRAGMA foreign_keys = ON")
|
||||||
|
connection.execute("PRAGMA journal_mode = WAL")
|
||||||
|
connection.execute("PRAGMA synchronous = NORMAL")
|
||||||
|
connection.execute("PRAGMA temp_store = MEMORY")
|
||||||
|
connection.execute("PRAGMA journal_size_limit = 1048576")
|
||||||
|
connection.execute(f"PRAGMA busy_timeout = {self.busy_timeout_ms}")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _ensure_auto_vacuum_full(self, connection:sqlite3.Connection) -> None:
|
||||||
|
current = connection.execute("PRAGMA auto_vacuum").fetchone()[0]
|
||||||
|
if current != 1:
|
||||||
|
connection.execute("PRAGMA auto_vacuum = FULL")
|
||||||
|
connection.execute("VACUUM")
|
||||||
|
|
||||||
|
# ── schema setup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _initialize_database(self) -> None:
|
||||||
|
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connect() as connection:
|
||||||
|
self._ensure_auto_vacuum_full(connection)
|
||||||
|
connection.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS games (
|
||||||
|
game_id TEXT PRIMARY KEY,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
source TEXT,
|
||||||
|
map_name TEXT,
|
||||||
|
ruleset_name TEXT,
|
||||||
|
ruleset_version TEXT,
|
||||||
|
your_snake_id TEXT,
|
||||||
|
your_snake_name TEXT,
|
||||||
|
your_snake_type TEXT,
|
||||||
|
your_snake_version TEXT,
|
||||||
|
winner_name TEXT,
|
||||||
|
winner_you INTEGER NOT NULL DEFAULT 0,
|
||||||
|
final_turn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
game_id TEXT NOT NULL,
|
||||||
|
turn INTEGER NOT NULL,
|
||||||
|
observed_at TEXT NOT NULL,
|
||||||
|
my_move TEXT,
|
||||||
|
my_thinking_json TEXT,
|
||||||
|
board_state_json TEXT NOT NULL,
|
||||||
|
snakes_json TEXT NOT NULL,
|
||||||
|
you_json TEXT NOT NULL,
|
||||||
|
food_json TEXT NOT NULL,
|
||||||
|
hazards_json TEXT NOT NULL,
|
||||||
|
UNIQUE (game_id, turn),
|
||||||
|
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS snake_turns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
game_id TEXT NOT NULL,
|
||||||
|
turn INTEGER NOT NULL,
|
||||||
|
snake_id TEXT NOT NULL,
|
||||||
|
snake_name TEXT,
|
||||||
|
health INTEGER,
|
||||||
|
length INTEGER,
|
||||||
|
head_x INTEGER,
|
||||||
|
head_y INTEGER,
|
||||||
|
body_json TEXT NOT NULL,
|
||||||
|
is_you INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inferred_move TEXT,
|
||||||
|
UNIQUE (game_id, turn, snake_id),
|
||||||
|
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
self._create_indexes_if_tables(connection)
|
||||||
|
self._ensure_column_exists(connection, "turns", "my_thinking_json", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "games", "your_snake_type", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "games", "your_snake_version", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "games", "game_type", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "snake_turns", "latency", "TEXT")
|
||||||
|
self._ensure_column_exists(connection, "games", "winner_name", "TEXT")
|
||||||
|
if self._zstd_available:
|
||||||
|
self._enable_zstd_compression(connection)
|
||||||
|
connection.execute("PRAGMA optimize")
|
||||||
|
|
||||||
|
def _create_indexes_if_tables(self, connection:sqlite3.Connection) -> None:
|
||||||
|
real_tables = {
|
||||||
|
row[0] for row in connection.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
indexes = [
|
||||||
|
("idx_turns_game_turn", "turns", "game_id, turn"),
|
||||||
|
("idx_games_status", "games", "status"),
|
||||||
|
("idx_snake_turns_game_turn", "snake_turns", "game_id, turn"),
|
||||||
|
]
|
||||||
|
for idx_name, table, cols in indexes:
|
||||||
|
if table in real_tables:
|
||||||
|
connection.execute(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table}({cols})")
|
||||||
|
|
||||||
|
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
|
||||||
|
obj = connection.execute(
|
||||||
|
"SELECT type FROM sqlite_master WHERE name = ?", (table_name,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if obj and obj["type"] == "view":
|
||||||
|
underlying = f"_{table_name}_zstd"
|
||||||
|
exists = connection.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE name = ? AND type = 'table'", (underlying,)
|
||||||
|
).fetchone()
|
||||||
|
if not exists:
|
||||||
|
return
|
||||||
|
actual_table = underlying
|
||||||
|
else:
|
||||||
|
actual_table = table_name
|
||||||
|
|
||||||
|
existing = connection.execute(f"PRAGMA table_info({actual_table})").fetchall()
|
||||||
|
if any(row["name"] == column_name for row in existing):
|
||||||
|
return
|
||||||
|
connection.execute(f"ALTER TABLE {actual_table} ADD COLUMN {column_name} {column_type}")
|
||||||
|
|
||||||
|
def _enable_zstd_compression(self, connection:sqlite3.Connection) -> None:
|
||||||
|
compressed_columns = [
|
||||||
|
("turns", "board_state_json"),
|
||||||
|
("turns", "snakes_json"),
|
||||||
|
("turns", "you_json"),
|
||||||
|
("turns", "food_json"),
|
||||||
|
("turns", "hazards_json"),
|
||||||
|
("snake_turns", "body_json"),
|
||||||
|
]
|
||||||
|
for table, column in compressed_columns:
|
||||||
|
try:
|
||||||
|
connection.execute(
|
||||||
|
"SELECT zstd_enable_transparent(?)",
|
||||||
|
[json.dumps({"table": table, "column": column, "compression_level": 6, "dict_chooser": "'a'"})],
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
connection.execute("SELECT zstd_incremental_maintenance(null, 1)")
|
||||||
|
|
||||||
|
# ── sync write methods ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _record_game_start_sync(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
||||||
|
game = game_state.get("game", {})
|
||||||
|
board = game_state.get("board", {})
|
||||||
|
you = self._extract_you(game_state)
|
||||||
|
ruleset = game.get("ruleset", {})
|
||||||
|
game_type = self._derive_game_type(board, ruleset)
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("""
|
||||||
|
INSERT INTO games (
|
||||||
|
game_id, started_at, width, height, source, map_name,
|
||||||
|
ruleset_name, ruleset_version, your_snake_id, your_snake_name,
|
||||||
|
your_snake_type, your_snake_version, game_type, status
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running')
|
||||||
|
ON CONFLICT(game_id) DO UPDATE SET
|
||||||
|
width = excluded.width,
|
||||||
|
height = excluded.height,
|
||||||
|
source = excluded.source,
|
||||||
|
map_name = excluded.map_name,
|
||||||
|
ruleset_name = excluded.ruleset_name,
|
||||||
|
ruleset_version = excluded.ruleset_version,
|
||||||
|
your_snake_id = excluded.your_snake_id,
|
||||||
|
your_snake_name = excluded.your_snake_name,
|
||||||
|
your_snake_type = excluded.your_snake_type,
|
||||||
|
your_snake_version = excluded.your_snake_version,
|
||||||
|
game_type = excluded.game_type,
|
||||||
|
status = 'running'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
game.get("id"),
|
||||||
|
self._utc_now(),
|
||||||
|
board.get("width"),
|
||||||
|
board.get("height"),
|
||||||
|
game.get("source"),
|
||||||
|
game.get("map"),
|
||||||
|
ruleset.get("name"),
|
||||||
|
ruleset.get("version"),
|
||||||
|
you.get("id"),
|
||||||
|
you.get("name"),
|
||||||
|
snake_type,
|
||||||
|
snake_version,
|
||||||
|
game_type,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
connection.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
|
connection.execute("PRAGMA optimize")
|
||||||
|
|
||||||
|
def _record_turn_sync(self, game_state:dict, my_move:str|None, my_thinking:dict|None) -> None:
|
||||||
|
game = game_state.get("game", {})
|
||||||
|
board = game_state.get("board", {})
|
||||||
|
snakes = self._extract_snakes(game_state)
|
||||||
|
you = self._extract_you(game_state)
|
||||||
|
game_id = game.get("id")
|
||||||
|
turn = int(game_state.get("turn", 0))
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("""
|
||||||
|
INSERT INTO turns (
|
||||||
|
game_id, turn, observed_at, my_move, my_thinking_json,
|
||||||
|
board_state_json, snakes_json, you_json, food_json, hazards_json
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(game_id, turn) DO UPDATE SET
|
||||||
|
observed_at = excluded.observed_at,
|
||||||
|
my_move = excluded.my_move,
|
||||||
|
my_thinking_json = excluded.my_thinking_json,
|
||||||
|
board_state_json = excluded.board_state_json,
|
||||||
|
snakes_json = excluded.snakes_json,
|
||||||
|
you_json = excluded.you_json,
|
||||||
|
food_json = excluded.food_json,
|
||||||
|
hazards_json = excluded.hazards_json
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
game_id,
|
||||||
|
turn,
|
||||||
|
self._utc_now(),
|
||||||
|
my_move,
|
||||||
|
self._to_json(my_thinking) if my_thinking is not None else None,
|
||||||
|
self._to_json(board),
|
||||||
|
self._to_json(snakes),
|
||||||
|
self._to_json(you),
|
||||||
|
self._to_json(board.get("food", [])),
|
||||||
|
self._to_json(board.get("hazards", [])),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_positions: dict[str, tuple[int, int]] = {}
|
||||||
|
if turn > 0:
|
||||||
|
previous_rows = connection.execute("""
|
||||||
|
SELECT snake_id, head_x, head_y
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = ? AND turn = ?
|
||||||
|
""",
|
||||||
|
(game_id, turn - 1),
|
||||||
|
).fetchall()
|
||||||
|
previous_positions = {
|
||||||
|
row["snake_id"]: (int(row["head_x"]), int(row["head_y"]))
|
||||||
|
for row in previous_rows
|
||||||
|
if row["head_x"] is not None and row["head_y"] is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
you_id = you.get("id")
|
||||||
|
for snake in snakes:
|
||||||
|
snake_id = snake.get("id")
|
||||||
|
head = snake.get("head", {})
|
||||||
|
head_x = head.get("x")
|
||||||
|
head_y = head.get("y")
|
||||||
|
if snake_id is None:
|
||||||
|
continue
|
||||||
|
new_head = (
|
||||||
|
(int(head_x), int(head_y))
|
||||||
|
if head_x is not None and head_y is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
inferred = self._infer_direction(previous_positions.get(snake_id), new_head)
|
||||||
|
connection.execute("""
|
||||||
|
INSERT INTO snake_turns (
|
||||||
|
game_id, turn, snake_id, snake_name, health, length,
|
||||||
|
head_x, head_y, body_json, is_you, inferred_move, latency
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(game_id, turn, snake_id) DO UPDATE SET
|
||||||
|
snake_name = excluded.snake_name,
|
||||||
|
health = excluded.health,
|
||||||
|
length = excluded.length,
|
||||||
|
head_x = excluded.head_x,
|
||||||
|
head_y = excluded.head_y,
|
||||||
|
body_json = excluded.body_json,
|
||||||
|
is_you = excluded.is_you,
|
||||||
|
inferred_move = excluded.inferred_move,
|
||||||
|
latency = excluded.latency
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
game_id,
|
||||||
|
turn,
|
||||||
|
snake_id,
|
||||||
|
snake.get("name"),
|
||||||
|
snake.get("health"),
|
||||||
|
snake.get("length"),
|
||||||
|
head_x,
|
||||||
|
head_y,
|
||||||
|
self._to_json(snake.get("body", [])),
|
||||||
|
1 if snake_id == you_id else 0,
|
||||||
|
inferred,
|
||||||
|
snake.get("latency"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute("""
|
||||||
|
UPDATE games
|
||||||
|
SET final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END
|
||||||
|
WHERE game_id = ?
|
||||||
|
""",
|
||||||
|
(turn, turn, game_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _record_game_end_sync(self, game_state:dict) -> None:
|
||||||
|
game = game_state.get("game", {})
|
||||||
|
game_id = game.get("id")
|
||||||
|
board = game_state.get("board", {})
|
||||||
|
snakes = list(board.get("snakes", []))
|
||||||
|
you = self._extract_you(game_state)
|
||||||
|
winner_name = next((snake.get("name") for snake in snakes if snake.get("name")), None)
|
||||||
|
you_id = you.get("id")
|
||||||
|
winner_you = any(snake.get("id") == you_id for snake in snakes)
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("""
|
||||||
|
UPDATE games
|
||||||
|
SET ended_at = ?,
|
||||||
|
winner_name = ?,
|
||||||
|
winner_you = ?,
|
||||||
|
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
|
||||||
|
status = 'finished'
|
||||||
|
WHERE game_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self._utc_now(),
|
||||||
|
winner_name,
|
||||||
|
1 if winner_you else 0,
|
||||||
|
int(game_state.get("turn", 0)),
|
||||||
|
int(game_state.get("turn", 0)),
|
||||||
|
game_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _finalize_stale_running_games_sync(self, stale_after_seconds:int=600) -> int:
|
||||||
|
threshold = max(60, int(stale_after_seconds))
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
finalized = 0
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute("""
|
||||||
|
SELECT game_id, started_at, final_turn, your_snake_id
|
||||||
|
FROM games
|
||||||
|
WHERE status = 'running'
|
||||||
|
ORDER BY started_at ASC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
started_at = self._parse_utc_timestamp(row["started_at"])
|
||||||
|
if started_at is None:
|
||||||
|
continue
|
||||||
|
if (now_utc - started_at).total_seconds() < threshold:
|
||||||
|
continue
|
||||||
|
|
||||||
|
game_id = row["game_id"]
|
||||||
|
your_snake_id = row["your_snake_id"]
|
||||||
|
final_turn = int(row["final_turn"] or 0)
|
||||||
|
|
||||||
|
snake_rows = connection.execute("""
|
||||||
|
SELECT snake_id, snake_name
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = ? AND turn = ?
|
||||||
|
ORDER BY is_you DESC, snake_name ASC
|
||||||
|
""",
|
||||||
|
(game_id, final_turn),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if len(snake_rows) == 0:
|
||||||
|
latest_row = connection.execute(
|
||||||
|
"SELECT MAX(turn) AS latest_turn FROM snake_turns WHERE game_id = ?",
|
||||||
|
(game_id,),
|
||||||
|
).fetchone()
|
||||||
|
if latest_row is not None and latest_row["latest_turn"] is not None:
|
||||||
|
final_turn = int(latest_row["latest_turn"])
|
||||||
|
snake_rows = connection.execute("""
|
||||||
|
SELECT snake_id, snake_name
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = ? AND turn = ?
|
||||||
|
ORDER BY is_you DESC, snake_name ASC
|
||||||
|
""",
|
||||||
|
(game_id, final_turn),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
survivor_ids = [s["snake_id"] for s in snake_rows if s["snake_id"]]
|
||||||
|
winner_you = bool(
|
||||||
|
your_snake_id
|
||||||
|
and your_snake_id in survivor_ids
|
||||||
|
and len(survivor_ids) == 1
|
||||||
|
)
|
||||||
|
survivor_name = next((s["snake_name"] for s in snake_rows if s["snake_name"]), None)
|
||||||
|
|
||||||
|
result = connection.execute("""
|
||||||
|
UPDATE games
|
||||||
|
SET ended_at = ?,
|
||||||
|
winner_name = ?,
|
||||||
|
winner_you = ?,
|
||||||
|
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
|
||||||
|
status = 'finished'
|
||||||
|
WHERE game_id = ? AND status = 'running'
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
self._utc_now(),
|
||||||
|
survivor_name,
|
||||||
|
1 if winner_you else 0,
|
||||||
|
final_turn,
|
||||||
|
final_turn,
|
||||||
|
game_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
finalized += 1
|
||||||
|
|
||||||
|
return finalized
|
||||||
|
|
||||||
|
# ── sync read methods ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _get_summary_sync(self, recent_limit:int=15) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
totals = connection.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_games,
|
||||||
|
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running_games,
|
||||||
|
SUM(CASE WHEN status = 'finished' THEN 1 ELSE 0 END) AS finished_games,
|
||||||
|
SUM(CASE WHEN status = 'finished' AND winner_you = 1 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN status = 'finished' AND winner_you = 0 THEN 1 ELSE 0 END) AS losses,
|
||||||
|
AVG(CASE WHEN status = 'finished' THEN final_turn ELSE NULL END) AS avg_turns
|
||||||
|
FROM games
|
||||||
|
""").fetchone()
|
||||||
|
|
||||||
|
by_type = connection.execute("""
|
||||||
|
SELECT
|
||||||
|
COALESCE(game_type, ruleset_name, 'unknown') AS type_label,
|
||||||
|
COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN status = 'finished' AND winner_you = 1 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN status = 'finished' AND winner_you = 0 THEN 1 ELSE 0 END) AS losses
|
||||||
|
FROM games
|
||||||
|
WHERE status = 'finished'
|
||||||
|
GROUP BY type_label
|
||||||
|
ORDER BY total DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
recent = connection.execute("""
|
||||||
|
SELECT game_id, started_at, ended_at, map_name, ruleset_name, game_type,
|
||||||
|
your_snake_name, your_snake_type, your_snake_version, winner_you, final_turn, status
|
||||||
|
FROM games
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(max(1, int(recent_limit)),),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_games": int(totals["total_games"] or 0),
|
||||||
|
"running_games": int(totals["running_games"] or 0),
|
||||||
|
"finished_games": int(totals["finished_games"] or 0),
|
||||||
|
"wins": int(totals["wins"] or 0),
|
||||||
|
"losses": int(totals["losses"] or 0),
|
||||||
|
"avg_turns_finished": round(float(totals["avg_turns"] or 0.0), 2),
|
||||||
|
"by_game_type": [{
|
||||||
|
"game_type": row["type_label"],
|
||||||
|
"total": int(row["total"]),
|
||||||
|
"wins": int(row["wins"]),
|
||||||
|
"losses": int(row["losses"]),
|
||||||
|
} for row in by_type],
|
||||||
|
"recent_games": [{
|
||||||
|
"game_id": row["game_id"],
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"ended_at": row["ended_at"],
|
||||||
|
"map": row["map_name"],
|
||||||
|
"ruleset": row["ruleset_name"],
|
||||||
|
"game_type": row["game_type"],
|
||||||
|
"snake": row["your_snake_name"],
|
||||||
|
"snake_type": row["your_snake_type"],
|
||||||
|
"snake_version": row["your_snake_version"],
|
||||||
|
"winner_you": bool(row["winner_you"]),
|
||||||
|
"final_turn": int(row["final_turn"] or 0),
|
||||||
|
"status": row["status"],
|
||||||
|
} for row in recent],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _list_games_sync(self, limit:int=50) -> list[dict]:
|
||||||
|
with self._connect() as connection:
|
||||||
|
rows = connection.execute("""
|
||||||
|
SELECT game_id, started_at, ended_at, map_name, source, ruleset_name, game_type,
|
||||||
|
your_snake_name, your_snake_type, your_snake_version,
|
||||||
|
winner_you, winner_name, final_turn, status
|
||||||
|
FROM games
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(max(1, int(limit)),),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"game_id": row["game_id"],
|
||||||
|
"started_at": row["started_at"],
|
||||||
|
"ended_at": row["ended_at"],
|
||||||
|
"map": row["map_name"],
|
||||||
|
"source": row["source"],
|
||||||
|
"ruleset": row["ruleset_name"],
|
||||||
|
"game_type": row["game_type"],
|
||||||
|
"snake": row["your_snake_name"],
|
||||||
|
"snake_type": row["your_snake_type"],
|
||||||
|
"snake_version": row["your_snake_version"],
|
||||||
|
"winner_you": bool(row["winner_you"]),
|
||||||
|
"winner_name": row["winner_name"],
|
||||||
|
"final_turn": int(row["final_turn"] or 0),
|
||||||
|
"status": row["status"],
|
||||||
|
} for row in rows]
|
||||||
|
|
||||||
|
def _get_game_replay_sync(self, game_id:str) -> dict|None:
|
||||||
|
with self._connect() as connection:
|
||||||
|
game_row = connection.execute("""
|
||||||
|
SELECT game_id, started_at, ended_at, width, height, source, map_name,
|
||||||
|
ruleset_name, ruleset_version, game_type, your_snake_id, your_snake_name,
|
||||||
|
your_snake_type, your_snake_version,
|
||||||
|
winner_name, winner_you, final_turn, status
|
||||||
|
FROM games
|
||||||
|
WHERE game_id = ?
|
||||||
|
""",
|
||||||
|
(game_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if game_row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
turn_rows = connection.execute("""
|
||||||
|
SELECT turn, observed_at, my_move, my_thinking_json,
|
||||||
|
board_state_json, food_json, hazards_json, you_json
|
||||||
|
FROM turns
|
||||||
|
WHERE game_id = ?
|
||||||
|
ORDER BY turn ASC
|
||||||
|
""",
|
||||||
|
(game_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
snake_rows = connection.execute("""
|
||||||
|
SELECT turn, snake_id, snake_name, health, length, head_x, head_y,
|
||||||
|
body_json, is_you, inferred_move, latency
|
||||||
|
FROM snake_turns
|
||||||
|
WHERE game_id = ?
|
||||||
|
ORDER BY turn ASC, is_you DESC, snake_name ASC
|
||||||
|
""",
|
||||||
|
(game_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
snakes_by_turn: dict[int, list[dict]] = {}
|
||||||
|
for row in snake_rows:
|
||||||
|
snakes_by_turn.setdefault(int(row["turn"]), []).append({
|
||||||
|
"snake_id": row["snake_id"],
|
||||||
|
"snake_name": row["snake_name"],
|
||||||
|
"health": row["health"],
|
||||||
|
"length": row["length"],
|
||||||
|
"head": {"x": row["head_x"], "y": row["head_y"]},
|
||||||
|
"body": self._from_json(row["body_json"]) or [],
|
||||||
|
"is_you": bool(row["is_you"]),
|
||||||
|
"inferred_move": row["inferred_move"],
|
||||||
|
"latency": row["latency"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"game": {
|
||||||
|
"game_id": game_row["game_id"],
|
||||||
|
"started_at": game_row["started_at"],
|
||||||
|
"ended_at": game_row["ended_at"],
|
||||||
|
"width": game_row["width"],
|
||||||
|
"height": game_row["height"],
|
||||||
|
"source": game_row["source"],
|
||||||
|
"map": game_row["map_name"],
|
||||||
|
"ruleset_name": game_row["ruleset_name"],
|
||||||
|
"ruleset_version": game_row["ruleset_version"],
|
||||||
|
"game_type": game_row["game_type"],
|
||||||
|
"your_snake_id": game_row["your_snake_id"],
|
||||||
|
"your_snake_name": game_row["your_snake_name"],
|
||||||
|
"your_snake_type": game_row["your_snake_type"],
|
||||||
|
"your_snake_version": game_row["your_snake_version"],
|
||||||
|
"winner_name": game_row["winner_name"],
|
||||||
|
"winner_you": bool(game_row["winner_you"]),
|
||||||
|
"final_turn": int(game_row["final_turn"] or 0),
|
||||||
|
"status": game_row["status"],
|
||||||
|
},
|
||||||
|
"turns": [
|
||||||
|
{
|
||||||
|
"turn": int(row["turn"]),
|
||||||
|
"observed_at": row["observed_at"],
|
||||||
|
"my_move": row["my_move"],
|
||||||
|
"my_thinking": self._from_json(row["my_thinking_json"]),
|
||||||
|
"board": self._from_json(row["board_state_json"]),
|
||||||
|
"food": self._from_json(row["food_json"]) or [],
|
||||||
|
"hazards": self._from_json(row["hazards_json"]) or [],
|
||||||
|
"you": self._from_json(row["you_json"]) or {},
|
||||||
|
"snakes": snakes_by_turn.get(int(row["turn"]), []),
|
||||||
|
}
|
||||||
|
for row in turn_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── public async interface ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
||||||
|
await asyncio.to_thread(self._record_game_start_sync, game_state, snake_type, snake_version)
|
||||||
|
|
||||||
|
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
||||||
|
await asyncio.to_thread(self._record_turn_sync, game_state, my_move, my_thinking)
|
||||||
|
|
||||||
|
async def record_game_end(self, game_state:dict) -> None:
|
||||||
|
await asyncio.to_thread(self._record_game_end_sync, game_state)
|
||||||
|
|
||||||
|
async def get_summary(self, recent_limit:int=15) -> dict:
|
||||||
|
return await asyncio.to_thread(self._get_summary_sync, recent_limit)
|
||||||
|
|
||||||
|
async def list_games(self, limit:int=50) -> list[dict]:
|
||||||
|
return await asyncio.to_thread(self._list_games_sync, limit)
|
||||||
|
|
||||||
|
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
|
||||||
|
return await asyncio.to_thread(self._finalize_stale_running_games_sync, stale_after_seconds)
|
||||||
|
|
||||||
|
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||||
|
return await asyncio.to_thread(self._get_game_replay_sync, game_id)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class GameplayBackendTemplate:
|
||||||
|
"""Abstract base for gameplay database backends.
|
||||||
|
|
||||||
|
Subclasses must override every method that raises NotImplementedError.
|
||||||
|
Shared pure-Python helpers (_utc_now, _to_json, etc.) live here so they
|
||||||
|
are available to both SQLite and PostgreSQL implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── public async interface ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Called once on server startup. Backends that need eager connection
|
||||||
|
(pool creation, schema init, migration) should override this."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def record_game_start(self, game_state:dict, snake_type:str|None=None, snake_version:str|None=None) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def record_turn(self, game_state:dict, my_move:str|None, my_thinking:dict|None=None) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def record_game_end(self, game_state:dict) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_summary(self, recent_limit:int=15) -> dict:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def list_games(self, limit:int=50) -> list[dict]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def finalize_stale_running_games(self, stale_after_seconds:int=600) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_game_replay(self, game_id:str) -> dict|None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── shared pure-python helpers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _utc_now(self) -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def _parse_utc_timestamp(self, value:str|None) -> datetime|None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
def _to_json(self, payload:object) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
|
def _from_json(self, payload:str|None) -> Any:
|
||||||
|
if payload is None or payload == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(payload)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_snakes(self, game_state:dict) -> list[dict]:
|
||||||
|
return list(game_state.get("board", {}).get("snakes", []))
|
||||||
|
|
||||||
|
def _extract_you(self, game_state:dict) -> dict:
|
||||||
|
return dict(game_state.get("you", {}))
|
||||||
|
|
||||||
|
def _infer_direction(self, old_head:tuple[int, int]|None, new_head:tuple[int, int]|None) -> str|None:
|
||||||
|
if old_head is None or new_head is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dx = new_head[0] - old_head[0]
|
||||||
|
dy = new_head[1] - old_head[1]
|
||||||
|
if dx == 1 and dy == 0:
|
||||||
|
return "right"
|
||||||
|
if dx == -1 and dy == 0:
|
||||||
|
return "left"
|
||||||
|
if dx == 0 and dy == 1:
|
||||||
|
return "up"
|
||||||
|
if dx == 0 and dy == -1:
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _derive_game_type(self, board:dict, ruleset:dict) -> str:
|
||||||
|
if len(board.get("snakes", [])) == 2:
|
||||||
|
return "duel"
|
||||||
|
|
||||||
|
return ruleset.get("name") or "standard"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from .Template import GameplayBackendTemplate
|
||||||
|
|
||||||
|
class GameplayBackendBuilder:
|
||||||
|
@staticmethod
|
||||||
|
def build(backend:str="sqlite", db_path:str|None=None, busy_timeout_ms:int=5000, pg_dsn:str|None=None, pg_min_size:int=1, pg_max_size:int=5) -> GameplayBackendTemplate:
|
||||||
|
normalized = (backend or "sqlite").strip().lower()
|
||||||
|
|
||||||
|
if normalized == "postgresql" or normalized == "postgres":
|
||||||
|
from .PostgresqlGameplayBackend import PostgresqlGameplayBackend
|
||||||
|
if not pg_dsn:
|
||||||
|
raise ValueError("pg_dsn is required for the postgresql backend")
|
||||||
|
return PostgresqlGameplayBackend(
|
||||||
|
dsn=pg_dsn,
|
||||||
|
min_size=pg_min_size,
|
||||||
|
max_size=pg_max_size,
|
||||||
|
sqlite_migration_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized == "sqlite":
|
||||||
|
from .SqliteGameplayBackend import SqliteGameplayBackend
|
||||||
|
if not db_path:
|
||||||
|
raise ValueError("db_path is required for the sqlite backend")
|
||||||
|
return SqliteGameplayBackend(db_path=db_path, busy_timeout_ms=busy_timeout_ms)
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown gameplay backend: {backend!r}. Choose 'sqlite' or 'postgresql'.")
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from server.GameBoard import GameBoard
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
class Dataset:
|
class Dataset:
|
||||||
VALID_MOVES = {"up", "down", "left", "right"}
|
VALID_MOVES = {"up", "down", "left", "right"}
|
||||||
|
|
||||||
def __init__(self, game_board:GameBoard):
|
def __init__(self, game_board:'GameBoard'):
|
||||||
self.game_board = game_board
|
self.game_board = game_board
|
||||||
|
|
||||||
def _did_we_win(self):
|
def _did_we_win(self):
|
||||||
|
|||||||
@@ -2,34 +2,18 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from quart_common.web.env import env_bool, env_int
|
||||||
from server.dataset.DatasetIO import DatasetIO
|
from server.dataset.DatasetIO import DatasetIO
|
||||||
|
|
||||||
class RLBootstrapDataset:
|
class RLBootstrapDataset:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.enabled = self._env_bool("RL_BOOTSTRAP_ENABLED", default=False)
|
self.enabled = env_bool("RL_BOOTSTRAP_ENABLED", default=False)
|
||||||
self.min_base_rows = self._env_int("RL_MIN_BASE_ROWS", default=5000)
|
self.min_base_rows = env_int("RL_MIN_BASE_ROWS", default=5000)
|
||||||
self.base_dataset_path = Path(os.getenv("RL_BASE_DATASET", "data/dataset/best_moves.jsonl"))
|
self.base_dataset_path = Path(os.getenv("RL_BASE_DATASET", "data/dataset/best_moves.jsonl"))
|
||||||
self.output_path = Path(os.getenv("RL_BOOTSTRAP_OUTPUT", "data/dataset/rl_bootstrap.jsonl"))
|
self.output_path = Path(os.getenv("RL_BOOTSTRAP_OUTPUT", "data/dataset/rl_bootstrap.jsonl"))
|
||||||
self.max_bytes = int(float(os.getenv("RL_BOOTSTRAP_MAX_MB", "50")) * 1024 * 1024)
|
self.max_bytes = int(float(os.getenv("RL_BOOTSTRAP_MAX_MB", "50")) * 1024 * 1024)
|
||||||
self.needs_more_data = False
|
self.needs_more_data = False
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _env_bool(name:str, default:bool=False) -> bool:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return value.lower() in {"1", "true", "yes", "on"}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _env_int(name:str, default:int) -> int:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
def refresh_state(self):
|
def refresh_state(self):
|
||||||
if not self.enabled:
|
if not self.enabled:
|
||||||
self.needs_more_data = False
|
self.needs_more_data = False
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
from server.GameBoard import GameBoard
|
|
||||||
|
|
||||||
class MemoryGameBoardStore:
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
self._state:dict[str, object] = {}
|
|
||||||
|
|
||||||
async def save(self, game_id:str, game_board:GameBoard) -> None:
|
|
||||||
self._state[game_id] = game_board
|
|
||||||
|
|
||||||
async def load(self, game_id:str):
|
|
||||||
return self._state.get(game_id)
|
|
||||||
|
|
||||||
async def delete(self, game_id:str) -> None:
|
|
||||||
self._state.pop(game_id, None)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
return None
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
from server.GameBoard import GameBoard
|
|
||||||
import inspect, pickle
|
|
||||||
|
|
||||||
class RedisGameBoardStore:
|
|
||||||
def __init__(self, redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:gameboard", ttl_seconds:int=900, **kwargs):
|
|
||||||
self.redis_url = redis_url
|
|
||||||
self.key_prefix = key_prefix
|
|
||||||
self.ttl_seconds = max(60, int(ttl_seconds))
|
|
||||||
self._redis = None
|
|
||||||
|
|
||||||
async def _get_redis(self):
|
|
||||||
if self._redis is not None:
|
|
||||||
return self._redis
|
|
||||||
|
|
||||||
try:
|
|
||||||
import redis.asyncio as aioredis # type: ignore[import-not-found]
|
|
||||||
except ImportError as error: # pragma: no cover
|
|
||||||
raise RuntimeError("Redis backend selected but 'redis' package with asyncio support is not installed") from error
|
|
||||||
|
|
||||||
self._redis = aioredis.from_url(self.redis_url)
|
|
||||||
return self._redis
|
|
||||||
|
|
||||||
def _key(self, game_id:str) -> str:
|
|
||||||
return f"{self.key_prefix}:{game_id}"
|
|
||||||
|
|
||||||
async def save(self, game_id:str, game_board:GameBoard) -> None:
|
|
||||||
redis = await self._get_redis()
|
|
||||||
payload = pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL)
|
|
||||||
await redis.set(self._key(game_id), payload, ex=self.ttl_seconds)
|
|
||||||
|
|
||||||
async def load(self, game_id:str):
|
|
||||||
redis = await self._get_redis()
|
|
||||||
payload = await redis.get(self._key(game_id))
|
|
||||||
if payload is None:
|
|
||||||
return None
|
|
||||||
return pickle.loads(payload)
|
|
||||||
|
|
||||||
async def delete(self, game_id:str) -> None:
|
|
||||||
redis = await self._get_redis()
|
|
||||||
await redis.delete(self._key(game_id))
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
if self._redis is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
aclose_method = getattr(self._redis, "aclose", None)
|
|
||||||
if callable(aclose_method):
|
|
||||||
maybe_result = aclose_method()
|
|
||||||
if inspect.isawaitable(maybe_result):
|
|
||||||
await maybe_result
|
|
||||||
else:
|
|
||||||
close_method = getattr(self._redis, "close", None)
|
|
||||||
if callable(close_method):
|
|
||||||
close_result = close_method()
|
|
||||||
if inspect.isawaitable(close_result):
|
|
||||||
await close_result
|
|
||||||
|
|
||||||
self._redis = None
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from .MemoryGameBoardStore import MemoryGameBoardStore
|
|
||||||
from .RedisGameBoardStore import RedisGameBoardStore
|
|
||||||
|
|
||||||
class GameStateStoreBuilder:
|
|
||||||
@classmethod
|
|
||||||
def build(self, backend:str="memory", **kwargs) -> MemoryGameBoardStore|RedisGameBoardStore:
|
|
||||||
selected = (backend or "memory").strip().lower()
|
|
||||||
if selected == "redis":
|
|
||||||
return RedisGameBoardStore(**kwargs)
|
|
||||||
return MemoryGameBoardStore(**kwargs)
|
|
||||||
@@ -3,12 +3,11 @@ from server.metrics.backends.Template import StoreTemplate
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
class MetricsCollector:
|
class MetricsCollector:
|
||||||
def __init__(self, metrics_manager:StoreTemplate, game_state_local_cache:bool, metrics_backend:str, game_state_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
|
def __init__(self, metrics_manager:StoreTemplate, metrics_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
|
||||||
self._manager = metrics_manager
|
self._manager = metrics_manager
|
||||||
self._stale_game_timeout_sec = stale_game_timeout_sec
|
self._stale_game_timeout_sec = stale_game_timeout_sec
|
||||||
self._game_last_seen_unix = game_last_seen_unix
|
self._game_last_seen_unix = game_last_seen_unix
|
||||||
self._game_move_counts = game_move_counts
|
self._game_move_counts = game_move_counts
|
||||||
self._game_state_backend_is_redis = game_state_backend.strip().lower() == 'redis'
|
|
||||||
self._metrics = {
|
self._metrics = {
|
||||||
'games_started': 0,
|
'games_started': 0,
|
||||||
'games_ended': 0,
|
'games_ended': 0,
|
||||||
@@ -39,7 +38,6 @@ class MetricsCollector:
|
|||||||
'last_game_end_unix': 0,
|
'last_game_end_unix': 0,
|
||||||
'last_move_unix': 0,
|
'last_move_unix': 0,
|
||||||
'games_stuck_removed': 0,
|
'games_stuck_removed': 0,
|
||||||
'game_state_local_cache_enabled': bool(game_state_local_cache),
|
|
||||||
'metrics_backend': metrics_backend,
|
'metrics_backend': metrics_backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,8 +99,6 @@ class MetricsCollector:
|
|||||||
await self._auto_publish()
|
await self._auto_publish()
|
||||||
|
|
||||||
async def record_stuck_removed(self) -> None:
|
async def record_stuck_removed(self) -> None:
|
||||||
if self._game_state_backend_is_redis:
|
|
||||||
return
|
|
||||||
self._metrics['games_stuck_removed'] += 1
|
self._metrics['games_stuck_removed'] += 1
|
||||||
await self._auto_publish()
|
await self._auto_publish()
|
||||||
|
|
||||||
@@ -117,20 +113,6 @@ class MetricsCollector:
|
|||||||
if now - last_seen >= self._stale_game_timeout_sec
|
if now - last_seen >= self._stale_game_timeout_sec
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._game_state_backend_is_redis:
|
|
||||||
# Redis auto-expires stale keys via TTL, so stale games are already gone from the
|
|
||||||
# server's perspective. We exclude them from all metrics so we only report games
|
|
||||||
# that are actually still alive in Redis.
|
|
||||||
report_active_games = len(game_last_seen_unix) - stale_candidates
|
|
||||||
report_stale_candidates = 0
|
|
||||||
# Only include non-stale timestamps when calculating the oldest active game age,
|
|
||||||
# so a game that Redis already deleted doesn't inflate the age metric.
|
|
||||||
active_last_seen = [
|
|
||||||
last_seen
|
|
||||||
for last_seen in game_last_seen_unix.values()
|
|
||||||
if now - last_seen < self._stale_game_timeout_sec
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
report_active_games = len(game_last_seen_unix)
|
report_active_games = len(game_last_seen_unix)
|
||||||
report_stale_candidates = stale_candidates
|
report_stale_candidates = stale_candidates
|
||||||
active_last_seen = list(game_last_seen_unix.values())
|
active_last_seen = list(game_last_seen_unix.values())
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ class StoreTemplate:
|
|||||||
"last_game_end_unix": 0,
|
"last_game_end_unix": 0,
|
||||||
"last_move_unix": 0,
|
"last_move_unix": 0,
|
||||||
"games_stuck_removed": 0,
|
"games_stuck_removed": 0,
|
||||||
"game_state_local_cache_enabled": False,
|
|
||||||
"metrics_backend": "redis",
|
"metrics_backend": "redis",
|
||||||
"active_games": 0,
|
"active_games": 0,
|
||||||
"tracked_games": 0,
|
"tracked_games": 0,
|
||||||
@@ -122,7 +121,6 @@ class StoreTemplate:
|
|||||||
merged["last_move_unix"] = max(merged["last_move_unix"], int(worker.get("last_move_unix", 0)))
|
merged["last_move_unix"] = max(merged["last_move_unix"], int(worker.get("last_move_unix", 0)))
|
||||||
merged["oldest_active_game_age_sec"] = max(merged["oldest_active_game_age_sec"], int(worker.get("oldest_active_game_age_sec", 0)))
|
merged["oldest_active_game_age_sec"] = max(merged["oldest_active_game_age_sec"], int(worker.get("oldest_active_game_age_sec", 0)))
|
||||||
merged["stale_game_timeout_sec"] = max(merged["stale_game_timeout_sec"], int(worker.get("stale_game_timeout_sec", 0)))
|
merged["stale_game_timeout_sec"] = max(merged["stale_game_timeout_sec"], int(worker.get("stale_game_timeout_sec", 0)))
|
||||||
merged["game_state_local_cache_enabled"] = merged["game_state_local_cache_enabled"] or bool(worker.get("game_state_local_cache_enabled", False))
|
|
||||||
|
|
||||||
for endpoint in merged["http_requests_by_endpoint"]:
|
for endpoint in merged["http_requests_by_endpoint"]:
|
||||||
merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0))
|
merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0))
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .dashboard_events import DashboardEventsService
|
||||||
|
from .dashboard_ws_hub import DashboardWebSocketHub
|
||||||
|
from .game_runtime import GameRuntimeService
|
||||||
|
from .gameplay_tracking import GameplayTrackingService
|
||||||
|
from .dashboard_query import DashboardQueryService
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
from quart_common.web.logger import await_log
|
||||||
|
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
import asyncio, inspect, json, time
|
||||||
|
|
||||||
|
class DashboardEventsService:
|
||||||
|
def __init__(self, enabled:bool, redis_url:str, channel:str, event_origin:str, shutdown_event:asyncio.Event, on_notice:Callable[[str], Awaitable[None]], logger):
|
||||||
|
self.enabled = enabled
|
||||||
|
self.redis_url = redis_url
|
||||||
|
self.channel = channel
|
||||||
|
self.event_origin = event_origin
|
||||||
|
self.shutdown_event = shutdown_event
|
||||||
|
self.on_notice = on_notice
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
self.listener_task:asyncio.Task|None=None
|
||||||
|
self.redis = None
|
||||||
|
self.pubsub = None
|
||||||
|
|
||||||
|
async def start_listener(self) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if self.listener_task is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
import redis.asyncio as aioredis # type: ignore[import-not-found]
|
||||||
|
|
||||||
|
self.redis = aioredis.from_url(self.redis_url)
|
||||||
|
self.pubsub = self.redis.pubsub()
|
||||||
|
await self.pubsub.subscribe(self.channel)
|
||||||
|
self.listener_task = asyncio.create_task(self._listener_loop())
|
||||||
|
except Exception as error:
|
||||||
|
self.listener_task = None
|
||||||
|
self.pubsub = None
|
||||||
|
self.redis = None
|
||||||
|
await await_log(self.logger.warning(f'Dashboard events listener disabled (redis unavailable): {error}'))
|
||||||
|
|
||||||
|
async def stop_listener(self) -> None:
|
||||||
|
listener_task = self.listener_task
|
||||||
|
self.listener_task = None
|
||||||
|
if listener_task is not None:
|
||||||
|
listener_task.cancel()
|
||||||
|
await asyncio.gather(listener_task, return_exceptions=True)
|
||||||
|
|
||||||
|
pubsub = self.pubsub
|
||||||
|
self.pubsub = None
|
||||||
|
if pubsub is not None:
|
||||||
|
try:
|
||||||
|
await pubsub.unsubscribe(self.channel)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
close_method = getattr(pubsub, 'aclose', None)
|
||||||
|
if callable(close_method):
|
||||||
|
try:
|
||||||
|
maybe_result = close_method()
|
||||||
|
if inspect.isawaitable(maybe_result):
|
||||||
|
await maybe_result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
redis_client = self.redis
|
||||||
|
self.redis = None
|
||||||
|
if redis_client is not None:
|
||||||
|
close_method = getattr(redis_client, 'aclose', None)
|
||||||
|
if callable(close_method):
|
||||||
|
try:
|
||||||
|
maybe_result = close_method()
|
||||||
|
if inspect.isawaitable(maybe_result):
|
||||||
|
await maybe_result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish_notice(self, trigger:str) -> None:
|
||||||
|
if not self.enabled:
|
||||||
|
return
|
||||||
|
if self.redis is None:
|
||||||
|
return
|
||||||
|
if trigger not in {'game_saved', 'stale_finalized', 'manual'}:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = {
|
||||||
|
'type': 'dashboard_games_update_notice',
|
||||||
|
'origin': self.event_origin,
|
||||||
|
'trigger': trigger,
|
||||||
|
'sent_at': int(time.time()),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self.redis.publish(self.channel, json.dumps(message))
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f'Dashboard events publish failed: {error}'))
|
||||||
|
|
||||||
|
async def _listener_loop(self) -> None:
|
||||||
|
pubsub = self.pubsub
|
||||||
|
if pubsub is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not self.shutdown_event.is_set():
|
||||||
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
|
||||||
|
if message is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_data = message.get('data')
|
||||||
|
if isinstance(raw_data, bytes):
|
||||||
|
payload_raw = raw_data.decode('utf-8', errors='replace')
|
||||||
|
else:
|
||||||
|
payload_raw = str(raw_data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
if payload.get('type') != 'dashboard_games_update_notice':
|
||||||
|
continue
|
||||||
|
if payload.get('origin') == self.event_origin:
|
||||||
|
continue
|
||||||
|
|
||||||
|
notice_trigger = str(payload.get('trigger') or 'game_saved')
|
||||||
|
await self.on_notice(notice_trigger)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f'Dashboard events listener stopped unexpectedly: {error}'))
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from quart_common.web.logger import await_log, logging
|
||||||
|
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
import json
|
||||||
|
|
||||||
|
from .dashboard_ws_hub import DashboardWebSocketHub
|
||||||
|
from server.database import GameplayDatabase
|
||||||
|
|
||||||
|
class DashboardQueryService:
|
||||||
|
def __init__(self, gameplay_database:GameplayDatabase, ws_hub:DashboardWebSocketHub, logger:logging, dashboard_running_game_stale_sec:int):
|
||||||
|
self.gameplay_database = gameplay_database
|
||||||
|
self.ws_hub = ws_hub
|
||||||
|
self.logger = logger
|
||||||
|
self.dashboard_running_game_stale_sec = dashboard_running_game_stale_sec
|
||||||
|
self.publish_notice:Callable[[str], Awaitable[None]] | None = None
|
||||||
|
|
||||||
|
def set_publish_notice(self, publish_notice:Callable[[str], Awaitable[None]]) -> None:
|
||||||
|
self.publish_notice = publish_notice
|
||||||
|
|
||||||
|
async def on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||||
|
await self.push_dashboard_games_update(
|
||||||
|
game_state=None,
|
||||||
|
publish_cluster=False,
|
||||||
|
trigger=trigger,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def build_dashboard_games_event(self, game_state:dict|None=None, trigger_override:str|None=None) -> dict:
|
||||||
|
games_payload = await self.get_dashboard_games(limit=100)
|
||||||
|
summary_payload = await self.get_dashboard_summary()
|
||||||
|
game_id = None
|
||||||
|
if game_state is not None:
|
||||||
|
game_id = game_state.get('game', {}).get('id')
|
||||||
|
trigger = trigger_override or ('game_saved' if game_id else 'snapshot')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'dashboard_games_update',
|
||||||
|
'trigger': trigger,
|
||||||
|
'games': games_payload,
|
||||||
|
'summary': summary_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def build_dashboard_game_replay_event(self, game_id:str, request_id:str|None=None) -> dict:
|
||||||
|
replay_payload = await self.get_dashboard_game_replay(game_id)
|
||||||
|
if replay_payload is None:
|
||||||
|
return {
|
||||||
|
'type': 'dashboard_game_replay',
|
||||||
|
'request_id': request_id,
|
||||||
|
'game_id': game_id,
|
||||||
|
'error': 'game_not_found',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'dashboard_game_replay',
|
||||||
|
'request_id': request_id,
|
||||||
|
'game_id': game_id,
|
||||||
|
'replay': replay_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handle_dashboard_ws_request(self, payload_raw:object) -> dict|None:
|
||||||
|
if not isinstance(payload_raw, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if payload.get('type') != 'dashboard_game_replay_request':
|
||||||
|
return None
|
||||||
|
|
||||||
|
game_id = str(payload.get('game_id') or '').strip()
|
||||||
|
request_id_raw = payload.get('request_id')
|
||||||
|
request_id = None if request_id_raw is None else str(request_id_raw)
|
||||||
|
if game_id == '':
|
||||||
|
return {
|
||||||
|
'type': 'dashboard_game_replay',
|
||||||
|
'request_id': request_id,
|
||||||
|
'error': 'missing_game_id',
|
||||||
|
}
|
||||||
|
|
||||||
|
return await self.build_dashboard_game_replay_event(
|
||||||
|
game_id=game_id,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def push_dashboard_games_update(self, game_state:dict|None=None, publish_cluster:bool=True, trigger:str|None=None) -> None:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return
|
||||||
|
event_payload = await self.build_dashboard_games_event(
|
||||||
|
game_state,
|
||||||
|
trigger_override=trigger,
|
||||||
|
)
|
||||||
|
await self.ws_hub.broadcast_payload(event_payload)
|
||||||
|
if publish_cluster and self.publish_notice is not None:
|
||||||
|
await self.publish_notice(str(event_payload.get('trigger') or ''))
|
||||||
|
|
||||||
|
async def get_dashboard_summary(self) -> dict:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return {'enabled': False}
|
||||||
|
try:
|
||||||
|
await self._finalize_stale_dashboard_games()
|
||||||
|
summary = await self.gameplay_database.get_summary()
|
||||||
|
summary['enabled'] = True
|
||||||
|
return summary
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f'Gameplay DB summary failed:{error}'))
|
||||||
|
return {'enabled': True, 'error': ' summary_unavailable'}
|
||||||
|
|
||||||
|
async def get_dashboard_games(self, limit:int=50) -> dict:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return {'enabled': False, 'games': []}
|
||||||
|
try:
|
||||||
|
await self._finalize_stale_dashboard_games()
|
||||||
|
games = await self.gameplay_database.list_games(limit=limit)
|
||||||
|
return {'enabled': True, 'games': games}
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(
|
||||||
|
self.logger.warning(f'Gameplay DB game list failed:{error}')
|
||||||
|
)
|
||||||
|
return {'enabled': True, 'error': 'games_unavailable', 'games': []}
|
||||||
|
|
||||||
|
async def get_dashboard_game_replay(self, game_id:str) -> dict|None:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return {'enabled': False, 'error': 'database_disabled', 'game_id': game_id}
|
||||||
|
try:
|
||||||
|
replay = await self.gameplay_database.get_game_replay(game_id)
|
||||||
|
if replay is None:
|
||||||
|
return None
|
||||||
|
replay['enabled'] = True
|
||||||
|
return replay
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f'Gameplay DB replay failed:{error}'))
|
||||||
|
return {'enabled': True, 'error': 'replay_unavailable', 'game_id': game_id}
|
||||||
|
|
||||||
|
async def _finalize_stale_dashboard_games(self) -> None:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.gameplay_database.finalize_stale_running_games(stale_after_seconds=self.dashboard_running_game_stale_sec)
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f'Gameplay DB stale running game finalize failed:{error}'))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import asyncio, json
|
||||||
|
|
||||||
|
class DashboardWebSocketHub:
|
||||||
|
def __init__(self):
|
||||||
|
self.subscribers:set[asyncio.Queue[str]] = set()
|
||||||
|
self.subscribers_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
self.ws_tasks:set[asyncio.Task] = set()
|
||||||
|
self.ws_tasks_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
self.shutdown_event = asyncio.Event()
|
||||||
|
self.shutdown_message = json.dumps({"type": "dashboard_ws_shutdown"})
|
||||||
|
|
||||||
|
async def register_subscriber(self, subscriber_queue:asyncio.Queue[str]) -> None:
|
||||||
|
async with self.subscribers_lock:
|
||||||
|
self.subscribers.add(subscriber_queue)
|
||||||
|
|
||||||
|
async def unregister_subscriber(self, subscriber_queue:asyncio.Queue[str]) -> None:
|
||||||
|
async with self.subscribers_lock:
|
||||||
|
self.subscribers.discard(subscriber_queue)
|
||||||
|
|
||||||
|
async def register_task(self, websocket_task:asyncio.Task) -> None:
|
||||||
|
async with self.ws_tasks_lock:
|
||||||
|
self.ws_tasks.add(websocket_task)
|
||||||
|
|
||||||
|
async def unregister_task(self, websocket_task:asyncio.Task) -> None:
|
||||||
|
async with self.ws_tasks_lock:
|
||||||
|
self.ws_tasks.discard(websocket_task)
|
||||||
|
|
||||||
|
async def broadcast_payload(self, payload:dict) -> None:
|
||||||
|
encoded_payload = json.dumps(payload)
|
||||||
|
async with self.subscribers_lock:
|
||||||
|
subscribers = tuple(self.subscribers)
|
||||||
|
|
||||||
|
for subscriber_queue in subscribers:
|
||||||
|
if subscriber_queue.full():
|
||||||
|
try:
|
||||||
|
subscriber_queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
subscriber_queue.put_nowait(encoded_payload)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def request_shutdown(self) -> None:
|
||||||
|
if self.shutdown_event.is_set():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.shutdown_event.set()
|
||||||
|
for subscriber_queue in tuple(self.subscribers):
|
||||||
|
if subscriber_queue.full():
|
||||||
|
try:
|
||||||
|
subscriber_queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
subscriber_queue.put_nowait(self.shutdown_message)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
continue
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from server.metrics import MetricsCollector
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
from snakes import SnakeBuilder
|
||||||
|
|
||||||
|
class GameRuntimeService:
|
||||||
|
def __init__(self, snake_type:str, stale_game_timeout_sec:int):
|
||||||
|
self.snake_type = snake_type
|
||||||
|
self.stale_game_timeout_sec = stale_game_timeout_sec
|
||||||
|
self.metrics_collector = None
|
||||||
|
|
||||||
|
self.running_games: dict[str, GameBoard] = {}
|
||||||
|
self.game_move_counts: dict[str, int] = {}
|
||||||
|
self.game_last_seen_unix: dict[str, int] = {}
|
||||||
|
|
||||||
|
def attach_metrics_collector(self, metrics_collector:MetricsCollector) -> None:
|
||||||
|
self.metrics_collector = metrics_collector
|
||||||
|
|
||||||
|
async def create_game_board(self, game_state:dict) -> GameBoard:
|
||||||
|
game_id = game_state['game']['id']
|
||||||
|
new_game_board = GameBoard(
|
||||||
|
game_id=game_id,
|
||||||
|
width=game_state['board']['width'],
|
||||||
|
height=game_state['board']['height'],
|
||||||
|
ruleset=game_state['game']['ruleset'],
|
||||||
|
source=game_state['game']['source'],
|
||||||
|
map=game_state['game']['map'],
|
||||||
|
snake_class=SnakeBuilder.build(self.snake_type),
|
||||||
|
)
|
||||||
|
await new_game_board.start_game(game_state)
|
||||||
|
|
||||||
|
self.running_games[game_id] = new_game_board
|
||||||
|
self.game_move_counts[game_id] = 0
|
||||||
|
self.game_last_seen_unix[game_id] = int(time.time())
|
||||||
|
if self.metrics_collector is not None:
|
||||||
|
await self.metrics_collector.record_game_started(len(self.game_last_seen_unix))
|
||||||
|
return new_game_board
|
||||||
|
|
||||||
|
async def delete_game_board(self, game_state:dict) -> None:
|
||||||
|
game_id = game_state['game']['id']
|
||||||
|
self.running_games.pop(game_id, None)
|
||||||
|
self.game_move_counts.pop(game_id, None)
|
||||||
|
self.game_last_seen_unix.pop(game_id, None)
|
||||||
|
|
||||||
|
async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||||
|
game_id = game_state['game']['id']
|
||||||
|
if game_id in self.running_games:
|
||||||
|
game_board = self.running_games[game_id]
|
||||||
|
else:
|
||||||
|
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)
|
||||||
|
if end:
|
||||||
|
game_board.end_game(game_state)
|
||||||
|
|
||||||
|
return game_board
|
||||||
|
|
||||||
|
async def prune_stale_games(self) -> None:
|
||||||
|
if not self.game_last_seen_unix:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
stale_ids = [
|
||||||
|
game_id
|
||||||
|
for game_id, last_seen in self.game_last_seen_unix.items()
|
||||||
|
if now - last_seen >= self.stale_game_timeout_sec
|
||||||
|
]
|
||||||
|
for game_id in stale_ids:
|
||||||
|
self.running_games.pop(game_id, None)
|
||||||
|
self.game_move_counts.pop(game_id, None)
|
||||||
|
self.game_last_seen_unix.pop(game_id, None)
|
||||||
|
if self.metrics_collector is not None:
|
||||||
|
await self.metrics_collector.record_stuck_removed()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from quart_common.web.logger import await_log, logging
|
||||||
|
|
||||||
|
from server.database import GameplayDatabase
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
class GameplayTrackingService:
|
||||||
|
def __init__(self, gameplay_database:GameplayDatabase, logger:logging):
|
||||||
|
self.gameplay_database = gameplay_database
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
async def record_gameplay_start(self, game_state:dict, game_board:GameBoard) -> None:
|
||||||
|
snake_name, snake_version = game_board.get_snake_name_and_version()
|
||||||
|
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.gameplay_database.record_game_start(
|
||||||
|
game_state,
|
||||||
|
snake_type=snake_name,
|
||||||
|
snake_version=snake_version,
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f"Gameplay DB start record failed:{error}"))
|
||||||
|
|
||||||
|
async def record_gameplay_turn(self, game_state:dict, my_move:str, game_board:GameBoard) -> None:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
thinking = self._extract_latest_snake_thinking(game_board)
|
||||||
|
await self.gameplay_database.record_turn(game_state, my_move, thinking)
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f"Gameplay DB turn record failed:{error}"))
|
||||||
|
|
||||||
|
async def record_gameplay_end(self, game_state:dict) -> None:
|
||||||
|
if self.gameplay_database is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.gameplay_database.record_game_end(game_state)
|
||||||
|
except Exception as error:
|
||||||
|
await await_log(self.logger.warning(f"Gameplay DB end record failed:{error}"))
|
||||||
|
|
||||||
|
def _extract_latest_snake_thinking(self, game_board:GameBoard) -> dict|None:
|
||||||
|
try:
|
||||||
|
history = game_board.snake_class.get_history()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(history, list) or len(history) == 0:
|
||||||
|
return None
|
||||||
|
latest = history[-1]
|
||||||
|
return latest if isinstance(latest, dict) else None
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
class StorageLoader:
|
|
||||||
@classmethod
|
|
||||||
def build(self, selected_storage: str):
|
|
||||||
storage_module = __import__(f"server.storage.{selected_storage}", fromlist=[selected_storage])
|
|
||||||
storage_class = getattr(storage_module, selected_storage)
|
|
||||||
return storage_class
|
|
||||||
@@ -4,6 +4,7 @@ from typing import Any, cast
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from quart_common.web.env import env_int
|
||||||
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
|
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
|
||||||
|
|
||||||
from snakes.TemplateSnake import TemplateSnake
|
from snakes.TemplateSnake import TemplateSnake
|
||||||
@@ -43,9 +44,9 @@ class BestBattleSnake(TemplateSnake):
|
|||||||
self.duel_style = self._get_duel_style()
|
self.duel_style = self._get_duel_style()
|
||||||
self.timeout_buffer_ms = self._get_timeout_buffer_ms()
|
self.timeout_buffer_ms = self._get_timeout_buffer_ms()
|
||||||
self.rl_bootstrap = RLBootstrapDataset()
|
self.rl_bootstrap = RLBootstrapDataset()
|
||||||
self.future_planning_depth = max(1, min(4, self._env_int("BATTLE_FUTURE_PLANNING_DEPTH", default=2)))
|
self.future_planning_depth = max(1, min(4, env_int("BATTLE_FUTURE_PLANNING_DEPTH", default=2)))
|
||||||
self.future_planning_branch = max(1, min(3, self._env_int("BATTLE_FUTURE_PLANNING_BRANCH", default=2)))
|
self.future_planning_branch = max(1, min(3, env_int("BATTLE_FUTURE_PLANNING_BRANCH", default=2)))
|
||||||
self.future_planning_min_time_ms = max(25, self._env_int("BATTLE_FUTURE_PLANNING_MIN_MS", default=70))
|
self.future_planning_min_time_ms = max(25, env_int("BATTLE_FUTURE_PLANNING_MIN_MS", default=70))
|
||||||
|
|
||||||
def _get_duel_style(self) -> str:
|
def _get_duel_style(self) -> str:
|
||||||
"""Resolve duel tuning style from `BATTLE_SNAKE_DUEL_STYLE` or `DUEL_STYLE`."""
|
"""Resolve duel tuning style from `BATTLE_SNAKE_DUEL_STYLE` or `DUEL_STYLE`."""
|
||||||
@@ -86,15 +87,6 @@ class BestBattleSnake(TemplateSnake):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return 120
|
return 120
|
||||||
|
|
||||||
def _env_int(self, name:str, default:int) -> int:
|
|
||||||
value = os.getenv(name)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
def choose_move(self, game_data:GameBoard) -> str:
|
def choose_move(self, game_data:GameBoard) -> str:
|
||||||
"""Pick the next move from a Battlesnake move request.
|
"""Pick the next move from a Battlesnake move request.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
from server.GameBoard import GameBoard
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
class TemplateSnake:
|
class TemplateSnake:
|
||||||
@@ -22,7 +26,7 @@ class TemplateSnake:
|
|||||||
def add_calculations(self, calculations:dict):
|
def add_calculations(self, calculations:dict):
|
||||||
self.calculations.append(calculations)
|
self.calculations.append(calculations)
|
||||||
|
|
||||||
def choose_move(self, game_data:GameBoard):
|
def choose_move(self, game_data:'GameBoard'):
|
||||||
self.game_board = game_data
|
self.game_board = game_data
|
||||||
self.calculations = []
|
self.calculations = []
|
||||||
self.eat_the_snake_overwrite = False
|
self.eat_the_snake_overwrite = False
|
||||||
@@ -198,3 +202,12 @@ class TemplateSnake:
|
|||||||
def set_target_food(self, target_food:dict):
|
def set_target_food(self, target_food:dict):
|
||||||
self.target_food = target_food
|
self.target_food = target_food
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
state = self.__dict__.copy()
|
||||||
|
state['history'] = [] # strip history — grows per turn, not needed for moves
|
||||||
|
state.pop('game_board', None) # re-set at top of every choose_move; circular ref
|
||||||
|
state.pop('calculations', None) # re-initialised at top of every choose_move
|
||||||
|
state.pop('eat_the_snake_overwrite', None) # re-initialised at top of every choose_move
|
||||||
|
state.pop('kill_the_snake', None) # per-call transient
|
||||||
|
return state
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ from typing import Any, cast
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
import heapq, os
|
import heapq, os
|
||||||
|
|
||||||
|
from quart_common.web.env import env_int
|
||||||
|
|
||||||
from snakes.TemplateSnake import TemplateSnake
|
from snakes.TemplateSnake import TemplateSnake
|
||||||
from server.GameBoard import GameBoard
|
from server.GameBoard import GameBoard
|
||||||
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
|
from server.dataset.RLBootstrapDataset import RLBootstrapDataset
|
||||||
|
|
||||||
class UltimateBattleSnake(TemplateSnake):
|
class UltimateBattleSnake(TemplateSnake):
|
||||||
"""
|
"""
|
||||||
UltimateBattleSnake v4.4.0
|
UltimateBattleSnake v4.5.0
|
||||||
|
|
||||||
All improvements over BestBattleSnake:
|
All improvements over BestBattleSnake:
|
||||||
v3: #1+#9 Simultaneous minimax (both snakes move at once) with hazard/health tracking
|
v3: #1+#9 Simultaneous minimax (both snakes move at once) with hazard/health tracking
|
||||||
@@ -50,9 +52,12 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
v4.4 D1 _minimax_sim: hazard spawn-immunity via previous_hazard_set (no damage on newly-spawned hazard)
|
v4.4 D1 _minimax_sim: hazard spawn-immunity via previous_hazard_set (no damage on newly-spawned hazard)
|
||||||
v4.4 D2 _hazard_will_kill: Dijkstra with per-tile stack cost (was constant entry_stack for whole corridor)
|
v4.4 D2 _hazard_will_kill: Dijkstra with per-tile stack cost (was constant entry_stack for whole corridor)
|
||||||
v4.4 D3 all random.choice fallbacks replaced with deterministic degrade (last_move > center > lexical)
|
v4.4 D3 all random.choice fallbacks replaced with deterministic degrade (last_move > center > lexical)
|
||||||
|
v4.5 E1 _enemy_can_grow_this_turn: health-urgency heuristic + occupied-set check (food blocked = no eat)
|
||||||
|
v4.5 E2 _flood_fill_count: per-turn frozenset-keyed transposition cache; resets each turn
|
||||||
|
v4.5 E3 Snail-specific trail scoring: adjacent hazard density + stack-risk penalty via self._is_snail
|
||||||
"""
|
"""
|
||||||
|
|
||||||
VERSION = "4.4.0"
|
VERSION = "4.5.0"
|
||||||
Point = tuple[int, int]
|
Point = tuple[int, int]
|
||||||
Coord = dict[str, int]
|
Coord = dict[str, int]
|
||||||
SnakeState = dict[str, Any]
|
SnakeState = dict[str, Any]
|
||||||
@@ -84,13 +89,28 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
self._enemy_dmaps: list[dict] = []
|
self._enemy_dmaps: list[dict] = []
|
||||||
self._enemy_heads: list[tuple[int, int]] = []
|
self._enemy_heads: list[tuple[int, int]] = []
|
||||||
self._base_blocked: set[tuple[int, int]] = set()
|
self._base_blocked: set[tuple[int, int]] = set()
|
||||||
|
self._is_snail: bool = False
|
||||||
|
# E2: per-turn transposition cache for flood-fill (reset each turn)
|
||||||
|
self._bfs_cache: dict[tuple, int] = {}
|
||||||
|
self._bfs_cache_turn: int = -1
|
||||||
# Config
|
# Config
|
||||||
self._planning_depth = max(1, min(4, self._env_int("BATTLE_FUTURE_PLANNING_DEPTH", 2)))
|
self._planning_depth = max(1, min(4, env_int("BATTLE_FUTURE_PLANNING_DEPTH", 2)))
|
||||||
self._planning_branch = max(1, min(3, self._env_int("BATTLE_FUTURE_PLANNING_BRANCH", 2)))
|
self._planning_branch = max(1, min(3, env_int("BATTLE_FUTURE_PLANNING_BRANCH", 2)))
|
||||||
self._planning_min_ms = max(25, self._env_int("BATTLE_FUTURE_PLANNING_MIN_MS", 70))
|
self._planning_min_ms = max(25, env_int("BATTLE_FUTURE_PLANNING_MIN_MS", 70))
|
||||||
# RL bootstrap dataset recorder
|
# RL bootstrap dataset recorder
|
||||||
self.rl_bootstrap = RLBootstrapDataset()
|
self.rl_bootstrap = RLBootstrapDataset()
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
state = super().__getstate__()
|
||||||
|
# strip per-turn precomputed state — all re-assigned at the top of choose_move
|
||||||
|
state['_enemy_dmaps'] = []
|
||||||
|
state['_enemy_heads'] = []
|
||||||
|
state['_base_blocked'] = set()
|
||||||
|
state['_is_snail'] = False
|
||||||
|
state['_bfs_cache'] = {}
|
||||||
|
state['_bfs_cache_turn'] = -1
|
||||||
|
return state
|
||||||
|
|
||||||
# ── Env helpers ──────────────────────────────────────────────────────────────
|
# ── Env helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_timeout_buffer_ms(self) -> int:
|
def _get_timeout_buffer_ms(self) -> int:
|
||||||
@@ -99,12 +119,6 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return 130
|
return 130
|
||||||
|
|
||||||
def _env_int(self, name: str, default: int) -> int:
|
|
||||||
try:
|
|
||||||
return int(os.getenv(name, str(default)))
|
|
||||||
except ValueError:
|
|
||||||
return default
|
|
||||||
|
|
||||||
def _get_duel_style(self) -> str:
|
def _get_duel_style(self) -> str:
|
||||||
raw = os.getenv("BATTLE_SNAKE_DUEL_STYLE", os.getenv("DUEL_STYLE", "balanced"))
|
raw = os.getenv("BATTLE_SNAKE_DUEL_STYLE", os.getenv("DUEL_STYLE", "balanced"))
|
||||||
style = raw.strip().lower()
|
style = raw.strip().lower()
|
||||||
@@ -131,7 +145,7 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
self.game_board = game_data
|
self.game_board = game_data
|
||||||
self.calculations = []
|
self.calculations = []
|
||||||
|
|
||||||
timeout_ms = game_data.get_timeout() if hasattr(game_data, "get_timeout") else 500
|
timeout_ms = (game_data.get_timeout() if hasattr(game_data, "get_timeout") else 500)
|
||||||
deadline = perf_counter() + (max(50, timeout_ms - self._get_timeout_buffer_ms()) / 1000.0)
|
deadline = perf_counter() + (max(50, timeout_ms - self._get_timeout_buffer_ms()) / 1000.0)
|
||||||
|
|
||||||
game_id = getattr(game_data, "id", None)
|
game_id = getattr(game_data, "id", None)
|
||||||
@@ -161,6 +175,12 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
game_map = game_data.get_map() if hasattr(game_data, "get_map") else None
|
game_map = game_data.get_map() if hasattr(game_data, "get_map") else None
|
||||||
is_constrictor = game_type == "constrictor"
|
is_constrictor = game_type == "constrictor"
|
||||||
is_snail = game_map in {"snail_mode", "snail"} or game_type == "snail_mode"
|
is_snail = game_map in {"snail_mode", "snail"} or game_type == "snail_mode"
|
||||||
|
self._is_snail = is_snail # E3: store for use in _score_move
|
||||||
|
|
||||||
|
# E2: reset per-turn BFS transposition cache
|
||||||
|
if turn != self._bfs_cache_turn:
|
||||||
|
self._bfs_cache = {}
|
||||||
|
self._bfs_cache_turn = turn
|
||||||
|
|
||||||
food_set: set[tuple[int, int]] = {(f["x"], f["y"]) for f in foods}
|
food_set: set[tuple[int, int]] = {(f["x"], f["y"]) for f in foods}
|
||||||
# C1: track hazard stack depth (Snail Mode can stack multiple hazards on one tile)
|
# C1: track hazard stack depth (Snail Mode can stack multiple hazards on one tile)
|
||||||
@@ -178,8 +198,13 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
total_body_cells = len(my_body) + sum(len(s["body"]) for s in other_snakes)
|
total_body_cells = len(my_body) + sum(len(s["body"]) for s in other_snakes)
|
||||||
total_occupancy = total_body_cells / board_area
|
total_occupancy = total_body_cells / board_area
|
||||||
|
|
||||||
|
# E1: build occupied set once so _enemy_can_grow_this_turn can skip food tiles under bodies
|
||||||
|
all_occupied: set[tuple[int, int]] = {(s["x"], s["y"]) for s in my_body}
|
||||||
|
for _s in other_snakes:
|
||||||
|
for _seg in _s["body"]:
|
||||||
|
all_occupied.add((_seg["x"], _seg["y"]))
|
||||||
enemy_can_grow = {
|
enemy_can_grow = {
|
||||||
s["id"]: self._enemy_can_grow_this_turn(s, food_set)
|
s["id"]: self._enemy_can_grow_this_turn(s, food_set, all_occupied)
|
||||||
for s in other_snakes if "id" in s
|
for s in other_snakes if "id" in s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,6 +784,25 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
if hazard_will_kill:
|
if hazard_will_kill:
|
||||||
score -= 10000.0
|
score -= 10000.0
|
||||||
|
|
||||||
|
# E3: Snail Mode trail scoring
|
||||||
|
# Penalise moves that place us in hazard-dense neighbourhoods (future stack risk),
|
||||||
|
# reward moves toward hazard-free space (safer continuation).
|
||||||
|
if self._is_snail and hazard_set:
|
||||||
|
adjacent_hazard_stack = sum(
|
||||||
|
hazard_count.get(n, 1)
|
||||||
|
for n in self._neighbors(point)
|
||||||
|
if n in hazard_set
|
||||||
|
)
|
||||||
|
# Each unit of adjacent total stack costs health faster next turn
|
||||||
|
if adjacent_hazard_stack > 0:
|
||||||
|
score -= adjacent_hazard_stack * 6.0
|
||||||
|
# Bonus for having hazard-free neighbours (escape routes)
|
||||||
|
hazard_free_neighbors = sum(
|
||||||
|
1 for n in self._neighbors(point)
|
||||||
|
if self._in_bounds(n, width, height) and n not in hazard_set and n not in blocked
|
||||||
|
)
|
||||||
|
score += hazard_free_neighbors * 8.0
|
||||||
|
|
||||||
# F12: territory call REMOVED from here — callers (_choose_*_move) apply it after _score_move
|
# F12: territory call REMOVED from here — callers (_choose_*_move) apply it after _score_move
|
||||||
|
|
||||||
score -= self._revisit_penalty(point)
|
score -= self._revisit_penalty(point)
|
||||||
@@ -1385,10 +1429,25 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
def _is_tail_stacked(self, body: list) -> bool:
|
def _is_tail_stacked(self, body: list) -> bool:
|
||||||
return len(body) >= 2 and body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"]
|
return len(body) >= 2 and body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"]
|
||||||
|
|
||||||
def _enemy_can_grow_this_turn(self, snake: dict, food_set: set) -> bool:
|
def _enemy_can_grow_this_turn(self, snake:dict, food_set:set, all_occupied:set|None=None) -> bool:
|
||||||
|
"""E1: Estimate if enemy will eat food this turn (tail won't vacate).
|
||||||
|
- Hungry enemies (health < 40) always assumed to eat accessible adjacent food.
|
||||||
|
- Healthy enemies assumed to eat unless the food tile is blocked by a body segment.
|
||||||
|
- all_occupied: full set of body tiles; food under a body can't be eaten this turn.
|
||||||
|
"""
|
||||||
head = snake["head"]
|
head = snake["head"]
|
||||||
|
health = snake.get("health", 100)
|
||||||
for dx, dy in self.DIRECTIONS.values():
|
for dx, dy in self.DIRECTIONS.values():
|
||||||
if (head["x"] + dx, head["y"] + dy) in food_set:
|
pt = (head["x"] + dx, head["y"] + dy)
|
||||||
|
if pt not in food_set:
|
||||||
|
continue
|
||||||
|
# Food blocked by a body segment: snake can't step there, so tail will still vacate
|
||||||
|
if all_occupied is not None and pt in all_occupied:
|
||||||
|
continue
|
||||||
|
# Hungry snakes (health < 40) will eat regardless of other factors
|
||||||
|
if health < 40:
|
||||||
|
return True
|
||||||
|
# Healthy snake with accessible adjacent food: conservative assumption → will eat
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1400,6 +1459,11 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
# ── Pathfinding primitives ────────────────────────────────────────────────────
|
# ── Pathfinding primitives ────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
|
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
|
||||||
|
# E2: transposition cache — frozenset key deduplicates identical blocked sets across branches
|
||||||
|
cache_key = (start, frozenset(blocked))
|
||||||
|
cached = self._bfs_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
queue = deque([start])
|
queue = deque([start])
|
||||||
seen = {start}
|
seen = {start}
|
||||||
while queue:
|
while queue:
|
||||||
@@ -1408,7 +1472,9 @@ class UltimateBattleSnake(TemplateSnake):
|
|||||||
if n not in seen and self._in_bounds(n, width, height) and n not in blocked:
|
if n not in seen and self._in_bounds(n, width, height) and n not in blocked:
|
||||||
seen.add(n)
|
seen.add(n)
|
||||||
queue.append(n)
|
queue.append(n)
|
||||||
return len(seen)
|
result = len(seen)
|
||||||
|
self._bfs_cache[cache_key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
|
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
|
||||||
return sum(
|
return sum(
|
||||||
|
|||||||
@@ -8,10 +8,19 @@ SNAKE_REGISTRY = {
|
|||||||
"BetterMasterSnake": "1.3.0",
|
"BetterMasterSnake": "1.3.0",
|
||||||
"BestBattleSnake": "2.6.0",
|
"BestBattleSnake": "2.6.0",
|
||||||
"TrainedBattleSnake": "0.1.0",
|
"TrainedBattleSnake": "0.1.0",
|
||||||
"UltimateBattleSnake": "4.4.0",
|
"UltimateBattleSnake": "4.5.0",
|
||||||
|
"ApexBattleSnake": "1.0.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
def build_snake(selected_snake: str):
|
DEFAULT_SNAKE_CONFIG = {
|
||||||
|
'apiversion': '1',
|
||||||
|
'author': '',
|
||||||
|
'color': '#888888',
|
||||||
|
'head': 'default',
|
||||||
|
'tail': 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_snake(selected_snake:str):
|
||||||
if selected_snake not in SNAKE_REGISTRY:
|
if selected_snake not in SNAKE_REGISTRY:
|
||||||
raise ValueError(f"Unknown snake: {selected_snake}")
|
raise ValueError(f"Unknown snake: {selected_snake}")
|
||||||
|
|
||||||
@@ -19,7 +28,7 @@ def build_snake(selected_snake: str):
|
|||||||
snake_class = getattr(snake_module, selected_snake)
|
snake_class = getattr(snake_module, selected_snake)
|
||||||
return snake_class()
|
return snake_class()
|
||||||
|
|
||||||
def get_snake_version(selected_snake: str) -> str | None:
|
def get_snake_version(selected_snake:str) -> str|None:
|
||||||
version = SNAKE_REGISTRY.get(selected_snake)
|
version = SNAKE_REGISTRY.get(selected_snake)
|
||||||
if version is None:
|
if version is None:
|
||||||
return None
|
return None
|
||||||
@@ -31,5 +40,5 @@ class SnakeBuilder:
|
|||||||
return build_snake(selected_snake)
|
return build_snake(selected_snake)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_version(self, selected_snake: str) -> str | None:
|
def get_version(self, selected_snake:str) -> str|None:
|
||||||
return get_snake_version(selected_snake)
|
return get_snake_version(selected_snake)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg-1: #f2eee6;
|
||||||
|
--bg-2: #e7dcc8;
|
||||||
|
--panel: #fffcf6;
|
||||||
|
--line: #d9ccb6;
|
||||||
|
--ink: #252119;
|
||||||
|
--muted: #6f6657;
|
||||||
|
--accent: #146a4b;
|
||||||
|
--accent-soft: #e5f2ed;
|
||||||
|
--danger: #b0492a;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #fffdf8;
|
||||||
|
--row-hover: #fdf4e7;
|
||||||
|
--row-active: #edf8f3;
|
||||||
|
--shadow: rgba(41, 29, 11, 0.08);
|
||||||
|
--you: #1a7a56;
|
||||||
|
--enemy: #bf5b33;
|
||||||
|
--snake-1: #bf5b33;
|
||||||
|
--snake-2: #2f6fdd;
|
||||||
|
--snake-3: #8d4ad6;
|
||||||
|
--snake-4: #cc7a11;
|
||||||
|
--snake-5: #0f8f84;
|
||||||
|
--snake-6: #be3f70;
|
||||||
|
--snake-7: #6b8a12;
|
||||||
|
--snake-8: #9a4a2f;
|
||||||
|
--snake-9: #2e8698;
|
||||||
|
--snake-10: #7f5fdd;
|
||||||
|
--food: #cca100;
|
||||||
|
--hazard: #6a5a9b;
|
||||||
|
--grid: #e6dbc8;
|
||||||
|
--cell: #ffffff;
|
||||||
|
--head-ring: #111111;
|
||||||
|
--mono-bg: #1d1b18;
|
||||||
|
--mono-ink: #ecdfcb;
|
||||||
|
--mono-vh-offset: 430px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg-1: #151819;
|
||||||
|
--bg-2: #1b2022;
|
||||||
|
--panel: #1f2527;
|
||||||
|
--line: #374144;
|
||||||
|
--ink: #e6e8e9;
|
||||||
|
--muted: #a8b1b3;
|
||||||
|
--accent: #4ec894;
|
||||||
|
--accent-soft: #233e35;
|
||||||
|
--danger: #d1734f;
|
||||||
|
--surface: #232b2e;
|
||||||
|
--surface-soft: #273134;
|
||||||
|
--row-hover: #2b3538;
|
||||||
|
--row-active: #224338;
|
||||||
|
--shadow: rgba(0, 0, 0, 0.35);
|
||||||
|
--you: #4ec894;
|
||||||
|
--enemy: #e2815a;
|
||||||
|
--snake-1: #e2815a;
|
||||||
|
--snake-2: #7ea8ff;
|
||||||
|
--snake-3: #c198ff;
|
||||||
|
--snake-4: #f2b857;
|
||||||
|
--snake-5: #67d2c8;
|
||||||
|
--snake-6: #ea86ad;
|
||||||
|
--snake-7: #b8d86b;
|
||||||
|
--snake-8: #e29d83;
|
||||||
|
--snake-9: #75c9da;
|
||||||
|
--snake-10: #b7a0ff;
|
||||||
|
--food: #ebc14b;
|
||||||
|
--hazard: #9b86d8;
|
||||||
|
--grid: #3b464a;
|
||||||
|
--cell: #1a2022;
|
||||||
|
--head-ring: #f3f5f6;
|
||||||
|
--mono-bg: #101416;
|
||||||
|
--mono-ink: #dce7e9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||||
|
background: linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
box-shadow: 0 8px 28px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.12rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, minmax(90px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
border: 1px solid #eadfcd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .k {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .v {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 330px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 28px var(--shadow);
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eadfcd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.games {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid #efe5d5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.active {
|
||||||
|
background: var(--row-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
#games-body tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px 1px;
|
||||||
|
border-bottom: 1px solid #eadfcd;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
line-height: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 34px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls>* {
|
||||||
|
margin: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #d2c3ab;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label span {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls select {
|
||||||
|
height: 24px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="range"] {
|
||||||
|
width: 150px;
|
||||||
|
min-width: 130px;
|
||||||
|
margin: 0;
|
||||||
|
height: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: #0f5a3f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#prev-btn,
|
||||||
|
#next-btn {
|
||||||
|
width: 52px;
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#play-btn {
|
||||||
|
width: 62px;
|
||||||
|
min-width: 62px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input[type="range"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 42%) 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-wrap {
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: var(--muted);
|
||||||
|
padding-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board {
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--grid);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
background: var(--cell);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-turn-cell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--turn-color, transparent);
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 50% = quarter-circle at the inner corner of the bend */
|
||||||
|
.snake-turn-cell.snake-turn-ur::after {
|
||||||
|
border-top-right-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-turn-cell.snake-turn-ul::after {
|
||||||
|
border-top-left-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-turn-cell.snake-turn-dr::after {
|
||||||
|
border-bottom-right-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-turn-cell.snake-turn-dl::after {
|
||||||
|
border-bottom-left-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.food {
|
||||||
|
background-image: radial-gradient(circle at center, #d73a31 0 45%, transparent 48%);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 78% 78%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard {
|
||||||
|
background-color: var(--hazard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 10, 50, 0.38) repeating-linear-gradient(135deg,
|
||||||
|
rgba(80, 60, 140, 0.6) 0,
|
||||||
|
rgba(80, 60, 140, 0.6) 2px,
|
||||||
|
transparent 2px,
|
||||||
|
transparent 6px);
|
||||||
|
z-index: 4;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-you {
|
||||||
|
background: var(--you);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-enemy {
|
||||||
|
background: var(--enemy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head {
|
||||||
|
outline: 2px solid var(--head-ring);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 30%;
|
||||||
|
top: 30%;
|
||||||
|
width: 40%;
|
||||||
|
height: 40%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--head-ring);
|
||||||
|
opacity: 0.9;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.head-style-1::after {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.head-style-2::after {
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.head-style-3::after {
|
||||||
|
width: 52%;
|
||||||
|
height: 28%;
|
||||||
|
top: 36%;
|
||||||
|
left: 24%;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.head-style-4::after {
|
||||||
|
width: 24%;
|
||||||
|
height: 56%;
|
||||||
|
top: 22%;
|
||||||
|
left: 38%;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.head-style-5::after {
|
||||||
|
width: 46%;
|
||||||
|
height: 46%;
|
||||||
|
top: 27%;
|
||||||
|
left: 27%;
|
||||||
|
clip-path: polygon(50% 0, 100% 100%, 0 100%);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.has-head-icon::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-head.has-head-icon {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you::after,
|
||||||
|
.snake-tail-enemy::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 6%;
|
||||||
|
top: 32%;
|
||||||
|
width: 38%;
|
||||||
|
height: 36%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.tail-style-1::after,
|
||||||
|
.snake-tail-enemy.tail-style-1::after {
|
||||||
|
width: 38%;
|
||||||
|
height: 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.tail-style-2::after,
|
||||||
|
.snake-tail-enemy.tail-style-2::after {
|
||||||
|
width: 24%;
|
||||||
|
height: 56%;
|
||||||
|
right: 10%;
|
||||||
|
top: 22%;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.tail-style-3::after,
|
||||||
|
.snake-tail-enemy.tail-style-3::after {
|
||||||
|
width: 44%;
|
||||||
|
height: 24%;
|
||||||
|
right: 8%;
|
||||||
|
top: 38%;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.tail-style-4::after,
|
||||||
|
.snake-tail-enemy.tail-style-4::after {
|
||||||
|
width: 34%;
|
||||||
|
height: 34%;
|
||||||
|
right: 10%;
|
||||||
|
top: 32%;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.tail-style-5::after,
|
||||||
|
.snake-tail-enemy.tail-style-5::after {
|
||||||
|
width: 42%;
|
||||||
|
height: 42%;
|
||||||
|
right: 8%;
|
||||||
|
top: 29%;
|
||||||
|
clip-path: polygon(100% 50%, 0 0, 0 100%);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.has-tail-icon::after,
|
||||||
|
.snake-tail-enemy.has-tail-icon::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-tail-you.has-tail-icon,
|
||||||
|
.snake-tail-enemy.has-tail-icon {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 2%;
|
||||||
|
background: var(--icon-color, currentColor);
|
||||||
|
-webkit-mask-image: var(--icon-url);
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-image: var(--icon-url);
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
transform: var(--icon-transform, rotate(0deg));
|
||||||
|
transform-origin: center;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-layer--tail {
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-layer--head {
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 1;
|
||||||
|
background: none;
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-layer--head>svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #e8dcc8;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid #ebdfcb;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip .k {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip .v {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reason-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-table td,
|
||||||
|
.score-table th {
|
||||||
|
border-bottom: 1px solid #f0e7d7;
|
||||||
|
padding: 6px;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-row {
|
||||||
|
background: var(--snake-row-bg, transparent);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, filter 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-row td {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-row td:first-child {
|
||||||
|
border-left: 4px solid var(--snake-row-color, transparent);
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-row.highlighted {
|
||||||
|
outline: 2px solid var(--snake-row-color, var(--line));
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snakes-section.has-highlight .snake-row:not(.highlighted) {
|
||||||
|
opacity: 0.25;
|
||||||
|
filter: grayscale(0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snake-row.dead-row {
|
||||||
|
filter: grayscale(0.55);
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-wrap {
|
||||||
|
display: inline-block;
|
||||||
|
width: 120px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(120, 120, 120, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
transition: width 120ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-text {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.82;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "IBM Plex Mono", "Consolas", monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--mono-bg);
|
||||||
|
color: var(--mono-ink);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
overflow: auto;
|
||||||
|
width: -webkit-fill-available;
|
||||||
|
height: -webkit-fill-available;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
resize: none;
|
||||||
|
max-height: calc(100vh - var(--mono-vh-offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.topbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
min-width: 0;
|
||||||
|
grid-template-columns: repeat(6, minmax(80px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-wrap {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.turn-badge {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.think-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.think-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="Layer_3" data-name="Layer 3"><path d="M46.77,21.35a28,28,0,1,0,28,28A28,28,0,0,0,46.77,21.35Zm0,50.86A22.83,22.83,0,1,1,69.6,49.38,22.83,22.83,0,0,1,46.77,72.21Z"/><path d="M50,0H0V100H50A50,50,0,0,0,50,0ZM73.4,15.46l1.18-1.41a1.42,1.42,0,0,1,2.17,1.82l-1.19,1.41a1.41,1.41,0,0,1-2.16-1.82ZM45.45,4.4a1.42,1.42,0,1,1,2.83,0V6.23a1.42,1.42,0,1,1-2.83,0ZM17.11,13.73a1.4,1.4,0,0,1,2,.18l1.19,1.4a1.42,1.42,0,0,1-2.17,1.82l-1.18-1.41A1.41,1.41,0,0,1,17.11,13.73ZM4.17,43.05l-1.81-.32A1.41,1.41,0,0,1,2.85,40l1.81.32a1.41,1.41,0,1,1-.49,2.78Zm6,28.82-1.59.91a1.41,1.41,0,1,1-1.41-2.44l1.59-.92a1.41,1.41,0,0,1,1.41,2.45ZM33.26,90.1l-.63,1.72a1.41,1.41,0,1,1-2.65-1l.63-1.73a1.41,1.41,0,0,1,2.65,1Zm29.2,2.65a1.42,1.42,0,0,1-1.81-.85L60,90.18a1.42,1.42,0,0,1,2.66-1l.63,1.73A1.42,1.42,0,0,1,62.46,92.75Zm18.38-23.7a1.41,1.41,0,0,1-1.93.52l-6.47-3.73-.13-.12a30.77,30.77,0,0,1-5.08,6s.11,0,.15.08l4.8,5.72A1.41,1.41,0,1,1,70,79.34l-4.8-5.72s0-.11,0-.16a30.31,30.31,0,0,1-6.8,4l.06.08,2.55,7a1.41,1.41,0,1,1-2.65,1l-2.56-7s0-.07,0-.1A30.35,30.35,0,0,1,48,79.67a.45.45,0,0,1,0,.11v7.47a1.42,1.42,0,1,1-2.83,0V79.78s0-.08,0-.13a29.82,29.82,0,0,1-7.71-1.37s0,.07,0,.11L35,85.4a1.41,1.41,0,0,1-2.66-1l2.56-7s0-.06.06-.1a30.56,30.56,0,0,1-6.77-4c0,.06,0,.12-.05.17l-4.8,5.72a1.41,1.41,0,1,1-2.16-1.81L26,71.66c0-.06.12,0,.18-.1a30.48,30.48,0,0,1-5-6.06.93.93,0,0,1-.15.13l-6.47,3.74a1.41,1.41,0,0,1-1.41-2.45l6.46-3.73a1.72,1.72,0,0,1,.21-.07,30.6,30.6,0,0,1-2.64-7.38,1.31,1.31,0,0,1-.21.08l-7.35,1.3A1.42,1.42,0,0,1,7.9,56a1.41,1.41,0,0,1,1.15-1.63L16.4,53a.82.82,0,0,1,.27,0,29.76,29.76,0,0,1-.25-3.67,30.08,30.08,0,0,1,.32-4.17,1.5,1.5,0,0,1-.3,0L9.09,43.92a1.42,1.42,0,1,1,.49-2.79l7.35,1.3a1.57,1.57,0,0,1,.3.12A30.09,30.09,0,0,1,20,35.19a1.24,1.24,0,0,1-.31-.1l-6.47-3.73a1.41,1.41,0,1,1,1.41-2.45l6.47,3.73a1.59,1.59,0,0,1,.27.24,30.26,30.26,0,0,1,5.16-6c-.12-.08-.27-.08-.37-.2L21.32,21a1.41,1.41,0,0,1,2.17-1.81l4.8,5.72c.1.12.07.27.13.41a30,30,0,0,1,6.86-4,1.57,1.57,0,0,1-.19-.33l-2.56-7a1.41,1.41,0,1,1,2.65-1l2.56,7a1.67,1.67,0,0,1,.06.38,30.28,30.28,0,0,1,7.73-1.3,1.59,1.59,0,0,1-.08-.39V11.23a1.42,1.42,0,1,1,2.83,0V18.7a1.35,1.35,0,0,1-.08.4,30.47,30.47,0,0,1,7.73,1.35,1.49,1.49,0,0,1,0-.36l2.55-7a1.41,1.41,0,0,1,2.66,1l-2.56,7a1.23,1.23,0,0,1-.19.31,30.1,30.1,0,0,1,6.83,4c.06-.12,0-.26.12-.37l4.8-5.72a1.41,1.41,0,0,1,2.17,1.81l-4.81,5.72c-.09.11-.23.11-.35.19a29.92,29.92,0,0,1,5.11,6.05,1.17,1.17,0,0,1,.24-.21L79,29.11a1.42,1.42,0,0,1,1.42,2.45L74,35.29a1.76,1.76,0,0,1-.3.1,30.17,30.17,0,0,1,2.7,7.36,1,1,0,0,1,.24-.09L84,41.36a1.42,1.42,0,0,1,1.64,1.15,1.41,1.41,0,0,1-1.15,1.63l-7.35,1.3a.82.82,0,0,1-.27,0,30.35,30.35,0,0,1,.29,4,29.31,29.31,0,0,1-.28,3.89.81.81,0,0,1,.22,0l7.35,1.29a1.42,1.42,0,1,1-.49,2.79l-7.35-1.3a.64.64,0,0,1-.19-.08,30.07,30.07,0,0,1-2.69,7.37,1,1,0,0,1,.16,0l6.47,3.73A1.42,1.42,0,0,1,80.84,69.05Zm5.91,3.42a1.42,1.42,0,0,1-1.93.52l-1.59-.92a1.41,1.41,0,0,1,1.41-2.45l1.6.92A1.41,1.41,0,0,1,86.75,72.47ZM91.18,43l-1.81.32a1.42,1.42,0,0,1-1.64-1.15,1.41,1.41,0,0,1,1.15-1.63l1.81-.32a1.39,1.39,0,0,1,1.63,1.14A1.41,1.41,0,0,1,91.18,43Z"/><path d="M39.76,49.38c0,10.79,7,19.54,7,19.54s7-8.75,7-19.54-7-19.54-7-19.54S39.76,38.59,39.76,49.38Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M94.56,68.55l-25.9,7.24S63.1,60.28,62,60.5s-17.93,8.71-17.93,8.71L32.22,60s-1.76-12.08-1.1-12.29,19.39,9.36,20.05,8.7,9.59-17.49,9.59-17.49l17,9L84.9,27.5l7.54,5.78,2.59-5c3.54-6.6,3.75-12.47,4.25-19.95C99.46,5.56,98.1,3.63,96,3L92.6,1.9C89.15.85,85.14,3,85.34,8.4c-.87.55-21.51,8.07-27.16,9.47a15.73,15.73,0,0,0-15-10.52c-8.75,0-16.78,7.2-15.74,16.14C21.27,22.1,4.89,6,0,0V100H31.34l7.25-5.11,12.73-.66,4,2.86H65.8l31.87-18.3a4.3,4.3,0,0,0,2.16-3.73C99.83,72.36,98.73,67,94.56,68.55ZM92.7,5.77c1.27,0,2.3,2.5,2.3,5.6S94,17,92.7,17s-2.31-2.5-2.31-5.59S91.42,5.77,92.7,5.77ZM43.2,14.55a8.19,8.19,0,0,1,8.34,8,8.35,8.35,0,0,1-16.69,0A8.19,8.19,0,0,1,43.2,14.55Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.antelope-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="antelope-cls-1" cx="47.25" cy="62.48" r="9" transform="translate(-9.4 8.38) rotate(-9.22)"/></g><g id="art"><path d="M33.27,23.17l.16.35C33.37,23.41,33.33,23.28,33.27,23.17Z"/><path d="M5.09,35.34A32.44,32.44,0,0,1,0,25.84V0C8.62,0,25.63,6.26,33.15,22.87l.12.3c.06.11.1.24.16.35,1.32,3.21,4,11.14,3.12,17.29a23.7,23.7,0,0,0,1.27-10.16l1.44,0C38.4,18.36,32.46,7.74,22.33,0A23,23,0,0,1,34.56,3.51a5.2,5.2,0,0,0-.92,2.39A8.54,8.54,0,0,1,35.24,4a28.22,28.22,0,0,1,6.31,5.93c-1.16,1.25-2.27,2.79-2.36,4.21A9.5,9.5,0,0,1,42.37,11a44.09,44.09,0,0,1,4.7,8.25c-1.62,1.13-4.07,3.13-4.45,5.13A10.18,10.18,0,0,1,47.82,21a60.88,60.88,0,0,1,2.41,7.45,15.68,15.68,0,0,0-5.31,2.38,4.51,4.51,0,0,0-1.12,1.25A8.82,8.82,0,0,1,46.16,31a11.15,11.15,0,0,1,3.54-.45,43.68,43.68,0,0,0,3.39-19.85A22.87,22.87,0,0,1,57.92,20a4.65,4.65,0,0,0-2.07.23,8.48,8.48,0,0,1,2.27.55,25.84,25.84,0,0,1,.36,8.08,7.28,7.28,0,0,0-4.35-.2,9.32,9.32,0,0,1,4.18,1.54A37.17,37.17,0,0,1,56.94,36C66.52,43.94,72,58.43,78.7,63.86c4.47,3.66,14.21,10.61,13.77,16.65S77.25,99,74.69,99.46c-5.16.89-12.4-4-12.29-11.38.32,5.71-4.28,6.6-7.25,5.83s-47-5.5-33.36-35.35c-4.5,2.52-10.21,13.51-7,21.3A113.08,113.08,0,0,0,0,100V38.12a14,14,0,0,1,3.67-2A25.48,25.48,0,0,0,9.9,40.08,24.48,24.48,0,0,1,5.09,35.34Zm73.2,55.39a2.59,2.59,0,0,0,4.3,1.06,50,50,0,0,0,7.35-9.24,5,5,0,0,0-.89-6.48c-2-1.56-8.51-.4-10.12.31C76.21,77.57,76.51,85,78.29,90.73Zm-31-19.25a9,9,0,1,0-9-9A9,9,0,0,0,47.25,71.48ZM23.66,31.68c.62,1.63,1.09,3,1.53,4l-4.45-2.47s-2.3,6.05-1,10.65a7.46,7.46,0,0,0,.61,1.55,8.88,8.88,0,0,1,.05-1.61,40.55,40.55,0,0,1,1.92-7.35A16.08,16.08,0,0,0,28,41.19a63.64,63.64,0,0,0-1.88-9.81C22.51,18.31,15.4,12.83,7.66,9.14A48.48,48.48,0,0,1,23.66,31.68Z"/><path d="M43.58,56.23a2.92,2.92,0,1,1-2.92,2.91A2.91,2.91,0,0,1,43.58,56.23Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M20.21,13.63a47.27,47.27,0,0,0,.3,73A49.42,49.42,0,0,0,25,82.48L20.44,82l0,0-.78-.08L21,78.78l1.59.18h0l4.79.52a47.48,47.48,0,0,0,5.24-9.24l-6.18.34,1.21-3.31,6.25-.35a51.37,51.37,0,0,0,2-7.74L30.56,61l.29-3.52,5.6-1.87a53.59,53.59,0,0,0,.3-5.56c0-1.43-.07-2.84-.18-4.24l-4.78,3.9-1-3.39L36.12,42a50.56,50.56,0,0,0-2.47-9.65l-5,4.67-1.22-3.31,4.87-4.58a47.15,47.15,0,0,0-6-9.89l-3.76,5.67-.29.42-2.15-3.22,4-5.43A32.09,32.09,0,0,0,20.21,13.63Z"/><path d="M73.54,84l-4.22,5.51-1.73-3.07,3.91-5.1a50.7,50.7,0,0,1-5.56-10l-4.87,4.56-1.2-3.32,4.82-4.52a54.17,54.17,0,0,1-2.28-9.17L56.1,62.26l-.29-3.52L62,55.5c-.17-1.8-.27-3.62-.27-5.47s.1-3.68.27-5.49l-6.53.81,1-3.39,6-.74a54.75,54.75,0,0,1,2.54-9.93l-6.05.32,1.21-3.31L66.25,28a50.87,50.87,0,0,1,6-10.29l-5.41-1,1.93-3,5.82,1a46.05,46.05,0,0,1,3.9-3.88A48,48,0,0,0,51.13,2.41h0a48,48,0,0,0-27.35,8.51,46.05,46.05,0,0,1,3.9,3.88l5.82-1,1.93,3-5.42,1A50.44,50.44,0,0,1,36,28l6.16.33,1.21,3.31-6-.32a54.75,54.75,0,0,1,2.54,9.93l6,.74,1,3.39-6.53-.81c.17,1.81.27,3.63.27,5.49s-.1,3.67-.27,5.47l6.14,3.24-.28,3.52-6.31-3.33a54.17,54.17,0,0,1-2.28,9.17l4.82,4.52-1.2,3.32-4.87-4.56a51.22,51.22,0,0,1-5.56,10l3.91,5.1L33,89.53,28.72,84a46.3,46.3,0,0,1-4.9,5.09A48,48,0,0,0,51.13,97.6h0a48,48,0,0,0,27.32-8.49A46.39,46.39,0,0,1,73.54,84Z"/><path d="M82.06,13.63a32.09,32.09,0,0,0-4,3l4,5.43L79.92,25.3l-.28-.42-3.76-5.67a47.15,47.15,0,0,0-5.95,9.89l4.86,4.58L73.58,37l-5-4.67A50.56,50.56,0,0,0,66.15,42l5.3,4.33-1,3.39-4.77-3.9c-.12,1.4-.18,2.81-.18,4.24a53.59,53.59,0,0,0,.3,5.56l5.6,1.87L71.71,61l-5.39-1.8a50.2,50.2,0,0,0,2,7.74l6.25.35,1.2,3.31-6.17-.34a47.44,47.44,0,0,0,5.23,9.24l4.8-.52,1.59-.18,1.35,3.12-.77.08,0,0-4.6.48a49.42,49.42,0,0,0,4.53,4.14,47.28,47.28,0,0,0,.3-73Z"/><path d="M1.13,50a50.15,50.15,0,0,1,50-50H0V100H51.13A50.15,50.15,0,0,1,1.13,50Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.puf-spec-head-1{fill:#0f0}.puf-spec-head-2{fill:#fff}.puf-spec-head-3{fill:#e84600}</style></defs><path d="M0,100s9.24-.1,14.82-.1c27.61,0,50-22.39,50-50S42.44-.1,14.82-.1C9.42-.1,0,0,0,0V100Z"/><path class="puf-spec-head-3" d="M56.95,64.82c.9,.37,1.8,.69,2.74,.92,.64,.16,1.27,.27,1.88,.33,2.22,.24,5.13,.29,8.22,.35,4.63,.09,9.88,.19,12.94,.94,.21,.05,.4,.1,.59,.16,2.66,.83,4.74,2.6,7.3,4.84-12.49,9.52-41.72,27-62.31,1.58-1.66-2.05,17.21-5.49,28.64-9.13Z"/><path class="puf-spec-head-3" d="M42.54,10.95c-2.66-1.86,53.36,13.85,55.5,58.12-6.75-2.8-10.57-6.13-16-6.46-5.43-.33-16.34,3.01-22.05,3.79-7.96,1.08-17.46-7.25-28.99-.52,4.16-8.11,9.5-12.49,19.94-7.21,4.77-2.96,20.08-27.79-8.39-47.71Z"/><path class="puf-spec-head-1" d="M32.9,19.38c-.14-.31-.26-.63-.36-.97l.36,.97Z"/><path class="puf-spec-head-2" d="M32.7,31.25c0,1.47,1.19,2.66,2.66,2.66s2.66-1.19,2.66-2.66-1.19-2.66-2.66-2.66-2.66,1.19-2.66,2.66Z"/><path class="puf-spec-head-2" d="M1.44,24.39C-.14,44.72,12.26,89.99,31.11,88.31c19.13-1.7,31.64-19.15,31.64-38.36,0-25.5-15.09-42.49-34.3-42.49-15.94,0-4.27,10.96-27.02,16.94Z"/><circle class="puf-spec-head-3" cx="56.55" cy="65.07" r="4.83"/><g><g><path d="M21.27,34.63c.39,6.01,5.72,10.68,11.98,9.99,4.93-.54,8.94-4.55,9.48-9.48,.68-6.16-3.84-11.43-9.72-11.96-.64-.06-1.83-.18-1.81,.46,.08,3.19-4.22,8.09-9.55,9.68-.45,.13-.4,.85-.37,1.31Z"/><circle class="puf-spec-head-2" cx="37.2" cy="33.78" r="5.61"/><circle cx="38.86" cy="33.75" r="3.93"/><circle class="puf-spec-head-2" cx="37.01" cy="35.72" r="1.62"/></g><path d="M28.55,44.11c-2.41-.69-15.45-8.35-20.65-9.61,11.88-1.18,20.31,7.09,20.31,7.09l.33,2.52Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.puf-head-1{fill:#fff;}</style></defs><circle class="puf-head-1" cx="36.98" cy="33.75" r="5.89"/><g><path d="M14.82-.05C25.28-.05,34.99,3.17,43.02,8.66c6.43,1.63,53.07,20.19,55.02,60.45-6.75-2.8-10.57-6.13-16-6.46-4.81-.29-13.9,2.29-19.89,3.42l-.03,.1c2.14,.19,4.83,.25,7.67,.3,4.63,.09,9.88,.19,12.94,.94,.21,.05,.4,.1,.59,.16,2.66,.83,4.74,2.6,7.3,4.84-8.7,6.63-25.53,17.12-41.91,14.28-8.91,8.22-20.81,13.24-33.89,13.24-5.58,0-14.82,.1-14.82,.1V.05S9.42-.05,14.82-.05ZM31.11,88.36c4.24-.38,8.15-1.53,11.68-3.3,6.56-3.3,11.76-8.74,15.21-15.35-.46,.14-.94,.24-1.44,.24-2.24,0-4.1-1.53-4.65-3.6-.09-.32-.14-.65-.15-1,0-.08-.02-.15-.02-.23,0-2.67,2.16-4.83,4.83-4.83,1.85,0,3.44,1.05,4.25,2.58,1.27-4.08,1.95-8.43,1.95-12.88,0-10.34-2.48-19.27-6.71-26.25C49.84,13.52,39.87,7.51,28.45,7.51c-15.94,0-4.27,10.96-27.02,16.94C-.14,44.77,12.26,90.04,31.11,88.36Z"/><g><g><path d="M21.74,33.31c5.33-1.58,9.64-6.49,9.55-9.68-.02-.64,1.17-.52,1.81-.46,5.19,.47,9.32,4.63,9.75,9.83-.38-2.73-2.71-4.83-5.55-4.83-3.1,0-5.61,2.51-5.61,5.61s2.51,5.61,5.61,5.61c2.87,0,5.23-2.15,5.56-4.93-.01,.23-.01,.45-.04,.68-.54,4.93-4.55,8.94-9.48,9.48-6.25,.69-11.58-3.98-11.98-9.99-.03-.46-.07-1.18,.37-1.31Z"/><path d="M38.74,35.72c0-.9-.73-1.62-1.62-1.62-.85,0-1.54,.65-1.61,1.48-.29-.55-.47-1.17-.47-1.83,0-2.17,1.76-3.93,3.93-3.93s3.81,1.66,3.91,3.74c0,.09,.01,.18,.01,.27-.04,2.14-1.78,3.86-3.93,3.86-.58,0-1.13-.13-1.63-.36,.79-.11,1.4-.78,1.4-1.6Z"/></g><path d="M28.65,44.11c-2.41-.69-15.45-8.35-20.65-9.61,11.88-1.18,20.31,7.09,20.31,7.09l.33,2.52Z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.bee-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="bee-cls-1" cx="60.27" cy="42.12" rx="11.72" ry="11.98"/></g><g id="art"><path d="M34.08,0H0V100c9.42,0,33.21.06,34.16,0-11-12-18.08-29.92-18.08-50S23.09,12,34.08,0Z"/><path d="M91.4,18a6.53,6.53,0,0,0-9,2.05l-.15.27a20.68,20.68,0,0,0-9.69-.43,62,62,0,0,0-6.81-6.56,21.23,21.23,0,0,1,5.82-2.77A7.28,7.28,0,1,0,70.8,7.3c0,.11,0,.22,0,.34a23.68,23.68,0,0,0-7.53,3.74A60.16,60.16,0,0,0,46,2.37C34.1,12,26.08,29.73,26.08,50c0,20.44,8.15,38.31,20.23,47.87A63.22,63.22,0,0,0,70.67,84.44C65.78,80.93,59.49,75,61,70.31c3.6,4.06,9.9,5.86,17.14,5.74a40.18,40.18,0,0,0,7.62-23.41c0-11.32-4.37-21.79-11.21-30.37a18.73,18.73,0,0,1,6.87.58A6.53,6.53,0,1,0,91.4,18ZM65,50a7.7,7.7,0,0,0,3.29-.74,10.08,10.08,0,1,1,0-14.18A7.7,7.7,0,0,0,65,34.29,7.83,7.83,0,1,0,65,50Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 904 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<path d="M0 100h56L32 88l-5-14 73 2-10-48L50 0H0zm23-61a9 9 0 1 1-10 10 9 9 0 0 1 10-10z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M25.91 21.49a3.65 3.65 0 0 1 .58.05 9.64 9.64 0 1 0 0 13.8 3.65 3.65 0 0 1-.58.05c-2.75 0-4.9-3.05-4.9-6.95s2.15-6.95 4.9-6.95z"/>
|
||||||
|
<path d="M100 43.78v-8.7a35 35 0 0 0-35-35L0 0v100h65.6c18.49 0 33-10.34 34.23-28.5h-7.24V43.78zm-80.22-2.7a12.64 12.64 0 1 1 12.63-12.64 12.65 12.65 0 0 1-12.63 12.64zM50 57.64a13.85 13.85 0 0 1 10.33-13.39V71A13.85 13.85 0 0 1 50 57.64zM64.6 71.5V43.78h9.74V71.5zm23.73 0h-9.74V43.78h9.74z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.bird-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="bird-cls-1" cx="47.62" cy="29.37" r="9.78"/></g><g id="art"><circle cx="45.4" cy="27.15" r="4.23"/><path d="M98.15,30.9,82,25.78a57.29,57.29,0,0,1,5.07,19.41l9.81-11.86-9.71-.86Z"/><path d="M39.83,0,0,0V100s6.79-9,37.85-9S83.32,74.29,85,50.08V50a53.33,53.33,0,0,0-6.61-25.91C70.43,9.82,56.16.24,39.83,0ZM51.33,66c-.72,15.09-22.71,17.54-37.95,3.45,4.45-.29,11.07-.86,12.22-1.87C21,66.25.73,59.19,5.18,40.87c2.73,3,9.06,6.1,17.11,8S52.18,48.1,51.33,66ZM47.62,39.15a9.78,9.78,0,1,1,9.78-9.78A9.77,9.77,0,0,1,47.62,39.15Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 671 B |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<g id="Layer_2_1_">
|
||||||
|
<path d="M99.8,41c-1.2-8.6-13.8-7.5-15.4-2.9c0,0-0.3-4.1-4.8-9.7h0.1l-0.3-1.1c-0.6-2-1.4-3.8-2.4-5.6c-0.9-1.6-2-3.1-3.2-4.4
|
||||||
|
c-0.9-0.9-1.9-1.8-3-2.4c1.1-2-0.3-5.6-1-6.9c-1.5-2.7-3.9-4.8-6.9-5.8c-2.8-0.9-5.7-0.8-8.9-0.7c-2.3,0.1-4.7,0.1-7,0.1h-2
|
||||||
|
c-5.7,0.1-11.3-0.1-17-0.6c-4.5-0.4-10.1-1-15.3,0.1L12,1.3C8.2,0.8,4.2,0.4,0,0v100c10.8,0.1,21.7-0.1,32.5-0.5
|
||||||
|
c9.4-0.4,18.8-0.9,28.1-1.9c8.4-0.9,20.7-0.7,28.1-4.5c6.7-3.4,9.8-10.4,7-17.1c0,0-20.7,20.4-45.7-9.2c0,0,48.1,3.5,45.6-5
|
||||||
|
c-0.9-3.5-2.3-6.9-4.2-10C91.5,51.8,101,49.7,99.8,41z M70.3,15.9c1,0.6,1.8,1.3,2.7,2.1c1.2,1.2,2.2,2.6,3,4.2
|
||||||
|
c0.8,1.4,1.5,2.8,2,4.3l0,0c-2.3-1-7.5-3.4-9-3.9C64.3,20.9,52.6,19,46.8,24c-0.3,0.2-0.5,0.5-0.8,0.8c1.1-3.4,3.4-6.4,6.5-8.2
|
||||||
|
c2.6-1.4,5.5-2.2,8.5-2.1C64.2,14.4,67.4,14.9,70.3,15.9z M29.1,77.7c-5.1,0-9.2-4.1-9.2-9.1c0-4.7,3.5-8.7,8.2-9.2
|
||||||
|
c0.2,0,0.4,0,0.6,0h0.4h0.1c0.6-3.4-0.2-6.9-2.1-9.7c-1.9-2.8-4.1-5.3-6.5-7.6c-0.6-0.6-1.2-1.2-1.7-1.7
|
||||||
|
c-8.7-8.9-13.3-17.7-13.6-26C5,11,6.2,4.1,11.5,2.5c0.5-0.1,0.9-0.2,1.4-0.3c0.8-0.2,1.6-0.3,2.5-0.4c1.3-0.1,2.7-0.2,4-0.2
|
||||||
|
c2.8,0,5.7,0.2,8.5,0.6C33.5,2.6,39.3,2.8,45,2.7h2c2.3,0,4.7,0,7.1-0.1c3.1-0.2,5.9-0.3,8.5,0.6c2.7,0.9,4.9,2.8,6.3,5.3
|
||||||
|
c0.8,1.6,1.8,4.9,0.8,6.1c-3.4-1-7-1.4-10.6-1.1c-2.5,0.2-4.8,1-7,2.2c-3.9,2.3-6.5,6.1-7.8,11.5c-0.6,1.1-1.2,2.3-1.6,3.5
|
||||||
|
l-0.5,1.2c-0.6,2-1.5,3.9-2.7,5.6c-0.7,1-1.6,2-2.4,2.9c-2.2,2.4-4.3,4.8-3.2,8.6c1.2,4.2,0.6,8-1.7,10.9l0.5,0.2l0.5,0.2
|
||||||
|
c4.5,2.3,6.4,7.8,4.1,12.3C35.8,75.8,32.6,77.7,29.1,77.7z M61,41.1c-5.2,1-8.4,7-9.4,1.8s1.7-14.2,6.9-15.1s10.9,6.4,11.9,11.6
|
||||||
|
S66.2,40.1,61,41.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.bull-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="bull-cls-1" d="M69.29,61.82s-2,3.19-2.8,4.29c-3,.1-4.67,1.75-6.53,3.62.54,1.42.33,6.14-.55,9.87C66.43,73.07,69.29,61.82,69.29,61.82Z"/></g><g id="art"><path d="M54,16.43c10.6-9.18,20.9-2.13,29.81-2.26C94.7,14,95.86,5,99.26.92c1.11,5.94.4,18.32-5.09,23.71s-11.36,3.26-17.27,4.1a10.54,10.54,0,0,0-5.52,2.52q1.94,1.84,3.81,3.68C92.09,51.4,70.33,79.28,61.8,85.86c-1.8,1.39-3,6.31-3.45,9.82a3.29,3.29,0,0,1-3.12,2.84c-15.79.6-30-6.21-33.37-12a4.91,4.91,0,0,1-.41-3.91c.68-2.16,3.8-7.56,5.95-11.55a3.92,3.92,0,0,0-1-4.86c-5.47-4.65-4.57-14.51-1.68-18.17C15.28,49.17,5.27,65.79,0,100V0C24.28,0,40.86,7.1,54,16.43ZM69.29,61.82s-2,3.19-2.8,4.29c-3,.1-4.67,1.75-6.53,3.62.54,1.42.33,6.14-.55,9.87C66.43,73.07,69.29,61.82,69.29,61.82Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M54.86,61.78H100V35.07a35,35,0,0,0-35-35L0,0V100H65c18.49,0,33.62-10.34,34.9-28.5h-45a4.86,4.86,0,0,1,0-9.72ZM42.5,41.33c-1.88,4.51-5.78,7.93-11.27,9.88a1.31,1.31,0,0,1-.43.08,1.29,1.29,0,0,1-.43-2.5c4.78-1.71,8.15-4.63,9.75-8.45a14.81,14.81,0,0,0-.59-12.06,13.35,13.35,0,0,0-9.39-6.88,11.88,11.88,0,0,0-10.42,2.3,11.08,11.08,0,0,0-1,15.64,8.61,8.61,0,0,0,12.15.78,6.64,6.64,0,0,0,.6-9.36,5,5,0,0,0-7.12-.45A3.77,3.77,0,0,0,24,35.64a2.77,2.77,0,0,0,3.9.25,1.92,1.92,0,0,0,.66-1.34,2,2,0,0,0-.48-1.42,1,1,0,0,0-.67-.23h0A.71.71,0,0,0,27,33a1.28,1.28,0,0,1-1.35,1.23,1.29,1.29,0,0,1-1.22-1.35,2.63,2.63,0,0,1,1.84-2.38,3.6,3.6,0,0,1,3.76.92,4.54,4.54,0,0,1-.41,6.4,5.36,5.36,0,0,1-3.87,1.32,5.3,5.3,0,0,1-3.67-1.81,6.36,6.36,0,0,1,.58-9,7.63,7.63,0,0,1,10.76.69,9.23,9.23,0,0,1-.83,13,11.21,11.21,0,0,1-15.79-1A13.67,13.67,0,0,1,18,21.76a14.42,14.42,0,0,1,12.64-2.88,15.73,15.73,0,0,1,11.21,8.31A17.36,17.36,0,0,1,42.5,41.33Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1020 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m62.81 43.67a6.33 6.33 0 0 0 -3.09 11.85 8.38 8.38 0 0 1 -1.75.19 8 8 0 1 1 7.09-11.62 6.47 6.47 0 0 0 -2.25-.42z" fill="#fff"/><path d="m90.29 68.22-3.92-5.81-6.4 6a3.66 3.66 0 0 0 -1.14 2.59 3.59 3.59 0 0 0 1 2.62l5.84 6.2a3.65 3.65 0 0 0 1.63 1c.78-.91 1.51-1.79 2.18-2.6a8.69 8.69 0 0 0 .81-10z"/><path d="m77.64 75.67a6.67 6.67 0 0 1 .28-9.42l6.75-6.36-.29-.43a22.45 22.45 0 0 1 -3.79-11.18l-.7-8.83c-.24-3.84-.84-9.66-3.52-12.41a85.64 85.64 0 0 0 -15.63-12.82 71.79 71.79 0 0 0 -19.15 6.88c5.41-7.89 31.6-17.73 31.6-17.73-9.59-2.81-73.19-3.37-73.19-3.37v100c9.47-15.78 25.11-16.55 31.26-16.11 2.64.18 16.44 11.55 29.33 12.39 10.41.72 18.67-6.53 24.61-13.1a6.54 6.54 0 0 1 -1.72-1.31zm-33.64-16.84a1.5 1.5 0 0 1 2.08-.42l12.72 8.49a1.5 1.5 0 0 1 .42 2.1 1.52 1.52 0 0 1 -1.25.67 1.41 1.41 0 0 1 -.83-.26l-12.73-8.5a1.5 1.5 0 0 1 -.41-2.08zm12.48 20.48a1.51 1.51 0 0 1 -1.48 1.34h-.17l-17.49-2a1.49 1.49 0 0 1 -1.34-1.65 1.51 1.51 0 0 1 1.66-1.33l17.48 2a1.49 1.49 0 0 1 1.34 1.64zm3.91-4.66a1.49 1.49 0 0 1 -1.92.91l-28.75-10.24a1.5 1.5 0 0 1 1-2.83l28.76 10.24a1.5 1.5 0 0 1 .91 1.92zm2.42-31a6.33 6.33 0 0 0 -3.09 11.85 8.38 8.38 0 0 1 -1.75.19 8 8 0 1 1 7.09-11.62 6.47 6.47 0 0 0 -2.25-.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.chef-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="chef-cls-1" cx="21.26" cy="28.86" r="16.67"/></g><g id="art"><path d="M80.31,32.64l1.82-2.49a58.71,58.71,0,0,0-7-7.2c-3-2.43-7.81-4.12-9.12-4.55l-1.78,2.43a44.05,44.05,0,0,1,8.69,4.86A80.42,80.42,0,0,1,80.31,32.64Z"/><path d="M94.77,13.62l-.32-.26a7.31,7.31,0,0,0-1.11-.65c-.22-.11-.44-.21-.65-.29a7.19,7.19,0,0,0-4.44-.09c.92-.9,2.94-1.34,4.27-1.52A9.75,9.75,0,0,0,80.9,2.92c.45,1.47.83,3.3.34,4.27a7.44,7.44,0,0,0-1.27-4,7.54,7.54,0,0,0-.67-.75L78.88,2l-.11-.1c-.14-.12-.26-.25-.41-.36s-.22-.14-.33-.21a7,7,0,0,0-7.92,11.44l-2.88,3.93a33.13,33.13,0,0,1,9.13,4.69,57,57,0,0,1,7,7.09l2.86-3.9a7,7,0,0,0,8.56-11ZM91.25,25.37c-3.23.5-6.19-1.34-6.61-4.1a3.88,3.88,0,0,1,0-.48c.66,2.49,3.46,4.1,6.5,3.63s5.23-2.83,5.12-5.42a3.94,3.94,0,0,1,.1.47C96.75,22.23,94.47,24.88,91.25,25.37Z"/><path d="M0,0V100c27.61,0,92.29,1.89,92.29-35.35C92.29,21.91,18.39,0,0,0ZM44.09,80.76c-2.49.29-7.65-.62-12.42-2.07-.41-2.51,1.57-8.84,3.28-11.31a1,1,0,0,0-.26-1.4,1,1,0,0,0-1.39.26A26.59,26.59,0,0,0,29.62,78c-4.21-1.47-7.71-3.32-8.1-5,0,0,.59-16.22,13.64-15.5C43.06,57.92,47.62,67.21,44.09,80.76ZM20.69,14.34A14.91,14.91,0,0,1,29,16.83a8.92,8.92,0,0,0-1.16-.08,9.93,9.93,0,0,0-9.93,9.93,9.56,9.56,0,0,0,.23,2.09A5.68,5.68,0,0,1,26.4,36.5a10.71,10.71,0,0,0,1.39.11,9.9,9.9,0,0,0,7.39-3.32A15,15,0,1,1,20.69,14.34ZM73.28,79.19c-7,4.08-16.79,6.26-26.61,6.26C34,85.45,21.2,81.79,14.33,73.91l-.14.16a9.25,9.25,0,0,1-6.56,2.8,10.65,10.65,0,0,1-5.26-1.49A1.07,1.07,0,0,1,2,73.92a1.05,1.05,0,0,1,1.45-.39c4.74,2.7,8.06.22,9.27-1,2.63-2.59,3.54-6.7,2.07-9.35a1.07,1.07,0,0,1,1.87-1c1.48,2.67,1.21,6.28-.48,9.3a.76.76,0,0,1,.09.09c10.75,13,40.39,13.86,55.53,5a1.5,1.5,0,1,1,1.51,2.59Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.chicken-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="chicken-cls-1" cx="60.96" cy="43.26" r="5.93"/></g><g id="art"><path d="M22.25,25.35c7.18,0,20.44-3.73,32.48-3l.32,0c6.24.43,12.13,2.09,16.64,6.14,1.5-3.33.86-8.69-2-13.76C65.88,8,59.56,4.28,55.58,6.51c-1.88,1.06-2.89,3.27-3,6a27.23,27.23,0,0,0-2.27-3.78C45,1.39,37.11-1.29,32.81,1.8,29.42,4.23,29.33,9.23,32,15.16c-3.7-2.2-7.82-2.56-10.89-.52s-4.23,5.62-3.83,9.67A12.91,12.91,0,0,0,22.25,25.35Z"/><path d="M83.67,82.23c-6.18-5.37-4.32-14.1-4.26-14.41a37.27,37.27,0,0,1-11,14.31c-.06.25-.1.51-.14.76C67,91.52,70.78,98,79.42,98,85.56,98,90.54,88.19,83.67,82.23Z"/><path d="M77.66,66.07a30.41,30.41,0,0,0,1.09-3,34.37,34.37,0,0,0,1-14.34,36,36,0,0,0-2.49-9.64,22.35,22.35,0,0,0-5.61-8C67.18,27,61.29,25.33,55.05,24.9l-.32,0c-12-.76-25.3,3-32.48,3a12.91,12.91,0,0,1-5-1C5.86,22,1,2.08,0,0V100c19.36-.12,48.85-4.82,66.69-19.62A37.27,37.27,0,0,0,77.66,66.07ZM23.24,68.82a16,16,0,0,1-2.76-.23C13.35,67.37,8,61.5,4.69,51.13a4.42,4.42,0,0,1,1.78-5.08c6.87-4.47,23.21-3.07,25-2.16-4.9.17-17.72,1-23.35,4.68A1.41,1.41,0,0,0,7.51,50c5,4.58,11.1,3.74,19.87,4.43-5.95,3.39-14,2.22-17.56,1.44a23.84,23.84,0,0,0,3.47,5.27c2.79.69,6.81,1.34,10.36.21C22.28,63,19.57,64,17.37,64.4A12.17,12.17,0,0,0,21,65.64c5.16.88,10.52-1.52,14.1-3.68A112.58,112.58,0,0,0,45.4,55C44.73,56.22,33.43,68.82,23.24,68.82ZM61,49.19a5.93,5.93,0,1,1,5.93-5.93A5.92,5.92,0,0,1,61,49.19Z"/><path d="M85.61,52.14s8.16-.63,13.3-.94A32.56,32.56,0,0,0,79.8,39.05a36,36,0,0,1,2.49,9.64,34.37,34.37,0,0,1-1,14.34c5.1-.59,14.2-4.25,17.69-8.53Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M99,14.64C88.61,4.21,74.27,0,61.06,0H0V100H61.06c14.06,0,28.55-4.83,38-14.64L63.69,50ZM38.68,45.53C31,45.53,24.8,37.24,24.8,27S31,8.51,38.68,8.51c3.88,0,7.39,2.14,9.9,5.57L35.64,27,48.58,40C46.07,43.39,42.56,45.53,38.68,45.53Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M48,0H0V100H48c27.61,0,52-22.39,52-50S75.61,0,48,0ZM64,82.85c-.8.06-1.61.1-2.42.1,0,0-45-.42-48.48-.42-2.7,0-5.37-3.29-5.5-6.12,0-.1,0-.21,0-.31a6.85,6.85,0,0,1,6.85-6.86l20.37,0a4.3,4.3,0,0,0-.42-8.57H14.56a10.9,10.9,0,0,1,0-21.79H43.3a3.78,3.78,0,0,0,0-7.56h-14a6.85,6.85,0,0,1-6.86-6.85V23.9a6.86,6.86,0,0,1,6.86-6.85h32.2c.81,0,1.62,0,2.42.1a32.94,32.94,0,0,1,0,65.7Z"/><circle cx="68.31" cy="50.05" r="19.12"/></svg>
|
||||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.cosmic-horror-c{fill:#fff}.cosmic-horror-d{fill:#ff7900}</style></defs><g id="a"><path d="M77.68,18.44c-1.54-6.97-7.75-12.18-15.17-12.18-2.97,0-5.74,.85-8.1,2.29-5.21-1.55-10.8-2.9-16.74-4.05C34.07,1.7,29.41,0,24.3,0,21.56,0,18.94,.5,16.55,1.38,11.22,.81,5.7,.35,0,0V100c19.07,0,39.38-.3,56.63-3.49,2.58,1.23,5.51,1.94,8.62,1.94,7.9,0,14.63-4.5,17.35-10.84,7.85-4.71,13.44-11.2,15.67-20.16,5.51-22.18-2.59-38.14-20.58-49.02Z"/></g><g id="b"><g><circle class="cosmic-horror-d" cx="23.09" cy="23.57" r="18.9"/><g><circle cx="28.02" cy="20.72" r="10.82"/><circle class="cosmic-horror-c" cx="22.55" cy="29.21" r="5.24"/><circle class="cosmic-horror-c" cx="31.07" cy="15.53" r="3.01"/></g></g><g><circle class="cosmic-horror-d" cx="16.62" cy="81.63" r="12.82"/><circle cx="16.69" cy="81.64" r="7.34"/><circle class="cosmic-horror-c" cx="14.72" cy="87.72" r="4.22"/><circle class="cosmic-horror-c" cx="19.91" cy="78.54" r="2.04"/></g><g><circle class="cosmic-horror-d" cx="84.1" cy="48.43" r="10.52"/><circle cx="88.96" cy="47.35" r="6.03"/><circle class="cosmic-horror-c" cx="84.85" cy="51.45" r="3.47"/><circle class="cosmic-horror-c" cx="90.93" cy="44.21" r="1.68"/></g><g><circle class="cosmic-horror-d" cx="40.08" cy="55.97" r="12.68"/><circle cx="42.24" cy="50.68" r="7.26"/><circle class="cosmic-horror-c" cx="39.68" cy="56.92" r="4.18"/><circle class="cosmic-horror-c" cx="45.51" cy="47.56" r="2.02"/></g><g><path class="cosmic-horror-d" d="M10.3,44.15c-3.07,0-5.56,2.49-5.56,5.56,0,.38,.04,.75,.11,1.11,.32-.27,.73-.44,1.18-.44,1.01,0,1.83,.82,1.83,1.83,0,.79-.5,1.45-1.19,1.71,.97,.84,2.24,1.35,3.63,1.35,3.07,0,5.56-2.49,5.56-5.56s-2.49-5.56-5.56-5.56Z"/><circle cx="7.66" cy="49.58" r="3.19"/><path class="cosmic-horror-c" d="M7.86,52.21c0-1.01-.82-1.83-1.83-1.83-.45,0-.86,.17-1.18,.44,.25,1.23,.91,2.31,1.82,3.1,.7-.26,1.19-.93,1.19-1.71Z"/><circle class="cosmic-horror-c" cx="8.43" cy="48.08" r=".89"/></g><g><circle class="cosmic-horror-d" cx="61.91" cy="23.65" r="13.85"/><circle cx="64.27" cy="17.87" r="7.93"/><circle class="cosmic-horror-c" cx="60.58" cy="25.05" r="4.56"/><circle class="cosmic-horror-c" cx="67.16" cy="14.6" r="2.21"/></g><g><circle class="cosmic-horror-d" cx="64.52" cy="79.03" r="15.02"/><circle cx="71.47" cy="78.61" r="8.6"/><circle class="cosmic-horror-c" cx="68.79" cy="85.46" r="4.95"/><circle class="cosmic-horror-c" cx="75.09" cy="74.37" r="2.39"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m77.68 18.44c-1.54-6.97-7.75-12.18-15.17-12.18-2.97 0-5.74.85-8.1 2.29-5.21-1.55-10.8-2.9-16.74-4.05-3.6-2.8-8.26-4.5-13.37-4.5-2.74 0-5.36.5-7.75 1.38-5.33-.57-10.85-1.03-16.55-1.38v100c19.07 0 39.38-.3 56.63-3.49 2.58 1.23 5.51 1.94 8.62 1.94 7.9 0 14.63-4.5 17.35-10.84 7.85-4.71 13.44-11.2 15.67-20.16 5.51-22.18-2.59-38.14-20.58-49.02z"/><circle cx="23.09" cy="23.57" fill="#fff" r="18.9"/><circle cx="28.02" cy="20.72" r="10.82"/><g fill="#fff"><circle cx="22.55" cy="29.21" r="5.24"/><circle cx="31.07" cy="15.53" r="3.01"/><circle cx="16.62" cy="81.63" r="12.82"/></g><circle cx="16.69" cy="81.64" r="7.34"/><circle cx="14.72" cy="87.72" fill="#fff" r="4.22"/><circle cx="19.91" cy="78.54" fill="#fff" r="2.04"/><circle cx="84.1" cy="48.43" fill="#fff" r="10.52"/><circle cx="88.96" cy="47.35" r="6.03"/><circle cx="84.85" cy="51.45" fill="#fff" r="3.47"/><circle cx="90.93" cy="44.21" fill="#fff" r="1.68"/><circle cx="40.08" cy="55.97" fill="#fff" r="12.68"/><circle cx="42.24" cy="50.68" r="7.26"/><circle cx="39.68" cy="56.92" fill="#fff" r="4.18"/><circle cx="45.51" cy="47.56" fill="#fff" r="2.02"/><path d="m10.3 44.15c-3.07 0-5.56 2.49-5.56 5.56 0 .38.04.75.11 1.11.32-.27.73-.44 1.18-.44 1.01 0 1.83.82 1.83 1.83 0 .79-.5 1.45-1.19 1.71.97.84 2.24 1.35 3.63 1.35 3.07 0 5.56-2.49 5.56-5.56s-2.49-5.56-5.56-5.56z" fill="#fff"/><circle cx="7.66" cy="49.58" r="3.19"/><path d="m7.86 52.21c0-1.01-.82-1.83-1.83-1.83-.45 0-.86.17-1.18.44.25 1.23.91 2.31 1.82 3.1.7-.26 1.19-.93 1.19-1.71z" fill="#fff"/><circle cx="8.43" cy="48.08" fill="#fff" r=".89"/><circle cx="61.91" cy="23.65" fill="#fff" r="13.85"/><circle cx="64.27" cy="17.87" r="7.93"/><circle cx="60.58" cy="25.05" fill="#fff" r="4.56"/><circle cx="67.16" cy="14.6" fill="#fff" r="2.21"/><circle cx="64.52" cy="79.03" fill="#fff" r="15.02"/><circle cx="71.47" cy="78.61" r="8.6"/><circle cx="68.79" cy="85.46" fill="#fff" r="4.95"/><circle cx="75.09" cy="74.37" fill="#fff" r="2.39"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<style>.cry-7{opacity:.4}.cry-1{fill:#999}.cry-2{fill:#b3b3b3}.cry-3{fill:#565656}.cry-4{fill:#3f3f3f}.cry-5{fill:gray}.cry-6{fill:#666}.cry-11,.cry-9{opacity:.4}.cry-10,.cry-20,.cry-8,.cry-9{fill:#fff}.cry-8{opacity:.2}.cry-10{isolation:isolate;opacity:.23}.cry-11{fill:#232323}.cry-12{fill:#3a3a3a}.cry-13{fill:#d37105}.cry-14{fill:#ba5e02}.cry-15{fill:#823907}.cry-16{fill:#703106}.cry-17{fill:#601d02}.cry-18{fill:#a34f02}.cry-19{fill:#e5801a}.cry-20{opacity:.37}.cry-21{fill:#000000}</style>
|
||||||
|
</defs>
|
||||||
|
<g id="crystals">
|
||||||
|
<polygon class="cry-1" points="52.85 9.84 21.16 53.77 16.23 3.18 52.85 9.84" />
|
||||||
|
<polygon class="cry-2" points="52.89 9.86 100 59.16 21.16 53.79 52.89 9.86" />
|
||||||
|
<polygon class="cry-3" points="87.77 88.89 21.16 53.79 17.76 96.76 87.77 88.89" />
|
||||||
|
<polygon class="cry-4" points="0 59.93 21.16 53.79 17.76 96.76 0 59.93" />
|
||||||
|
<polygon points="0 59.93 0 100 17.76 96.76 0 59.93" />
|
||||||
|
<polygon class="cry-5" points="0 59.93 21.16 53.79 16.19 3.2 0 59.93" />
|
||||||
|
<polygon points="0 59.93 0 0 16.19 3.2 0 59.93" />
|
||||||
|
<polygon class="cry-6" points="100 59.16 55.91 74.03 87.54 88.89 100 59.16" />
|
||||||
|
<polygon class="cry-4" points="17.76 96.76 55.91 74.03 87.54 88.89 17.76 96.76" />
|
||||||
|
<polygon class="cry-5" points="100 59.16 55.91 74.03 21.16 53.79 100 59.16" />
|
||||||
|
</g>
|
||||||
|
<g id="overlay">
|
||||||
|
<g class="cry-7">
|
||||||
|
<polygon points="52.85 9.84 16.23 3.18 21.16 53.77 52.85 9.84" />
|
||||||
|
<polygon points="100 59.16 52.89 9.86 16.19 3.2 16.19 3.2 0 59.93 17.76 96.76 17.76 96.76 87.77 88.89 87.58 88.79 100 59.16" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<g id="shines">
|
||||||
|
<polygon class="cry-8" points="19.28 35.09 19.96 52.96 4.89 58.51 20.1 55.3 19.88 70.69 21.84 55.32 41.51 65.64 23.57 54.68 39.72 55.05 22.78 52.82 25.48 48.26 21.79 51.59 19.28 35.09" />
|
||||||
|
<polygon class="cry-8" points="20.56 49.14 20.56 53.5 17.11 54.93 20.48 54.6 20.8 58.07 21.51 54.66 25.48 56.29 22.33 54.2 24.38 54.06 21.95 53.41 22.83 51.62 21.43 52.89 20.56 49.14" />
|
||||||
|
<polygon class="cry-8" points="73.75 68.05 55.8 73.34 45.32 67.86 54.35 74.07 45.43 79.98 55.85 74.9 66.33 78.81 57.14 73.9 73.75 68.05" />
|
||||||
|
<polygon class="cry-9" points="100 59.16 56.63 13.85 91.98 54.72 67.83 43.77 94.08 57.97 82.96 58.07 95.83 59.76 85.56 64.03 100 59.16" />
|
||||||
|
<path class="cry-10" d="M17.85,8.63a7,7,0,0,0,9.23,4.75c-4,1.29-5.55,6.74-4.75,9.24-.85-2.66-5.49-5.95-9.23-4.76A7.14,7.14,0,0,0,17.85,8.63Z" />
|
||||||
|
<path class="cry-10" d="M85.4,69.63a5.55,5.55,0,0,0,2.86,7.74c-3.05-1.4-6.87,1-7.74,2.86.93-2,0-6.44-2.86-7.74A5.67,5.67,0,0,0,85.4,69.63Z" />
|
||||||
|
</g>
|
||||||
|
<g id="eye">
|
||||||
|
<polygon class="cry-11" points="26.26 26.51 26.26 40.44 38.32 47.4 50.38 40.44 50.38 26.51 38.32 19.55 26.26 26.51" />
|
||||||
|
<polygon class="cry-12" points="26.88 26.88 26.88 40.08 38.32 46.68 49.75 40.08 49.75 26.88 38.36 20.27 26.88 26.88" />
|
||||||
|
<polygon class="cry-13" points="27.87 26.95 27.87 39.09 38.38 45.16 48.89 39.09 48.89 26.95 38.42 20.89 27.87 26.95" />
|
||||||
|
<polygon class="cry-14" points="38.38 33.02 48.91 39.05 48.86 26.91 38.38 33.02" />
|
||||||
|
<polygon class="cry-15" points="38.38 33.02 27.87 26.95 38.42 20.89 38.38 33.02" />
|
||||||
|
<polygon class="cry-16" points="38.38 33.02 27.84 39.05 27.9 26.91 38.38 33.02" />
|
||||||
|
<polygon class="cry-17" points="38.38 33.02 38.38 45.16 27.84 39.04 38.38 33.02" />
|
||||||
|
<polygon class="cry-18" points="38.38 33.02 38.38 45.16 48.91 39.04 38.38 33.02" />
|
||||||
|
<polygon class="cry-19" points="30.28 28.35 30.28 37.7 38.38 42.37 46.48 37.7 46.48 28.35 38.41 23.67 30.28 28.35" />
|
||||||
|
<path class="cry-21" d="M41.89,33c0,4.47-3.47,8.09-3.47,8.09S34.94,37.49,34.94,33s3.48-8.09,3.48-8.09S41.89,28.55,41.89,33Z" />
|
||||||
|
<circle class="cry-20" cx="36.15" cy="30.41" r="2.58" />
|
||||||
|
<circle class="cry-20" cx="40.97" cy="36.22" r="1.33" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.cute-dragon-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="cute-dragon-cls-1" d="M69.92,50.15a11.54,11.54,0,0,0-1.21-3c-2.53-4.81-7.87-7-11.92-4.87s-5.6,8-3.06,12.85a10.11,10.11,0,0,0,1.5,2.14,9.75,9.75,0,0,0,7.17,3.17A9.2,9.2,0,0,0,66,59.7c4.21-1.77,5.06-5.63,4-9.56"/></g><g id="art"><path d="M63.25,46.75a5.64,5.64,0,1,1-5.64,5.64A5.63,5.63,0,0,1,63.25,46.75Zm.35,10.63a.82.82,0,1,0-.82-.82A.82.82,0,0,0,63.6,57.38ZM62.49,53a1.64,1.64,0,1,0-1.64-1.63A1.63,1.63,0,0,0,62.49,53Z"/><path d="M14.71,15.34a1.17,1.17,0,0,0,2-.95L15.49,4.68a2.34,2.34,0,0,1,4-1.53L33.06,17.93a1.17,1.17,0,0,0,2-.67l.79-7.58a2.34,2.34,0,0,1,4.2-1.32c3,4,6.65,9.29,8.8,12.45a1.17,1.17,0,0,0,2.1-.42,19.7,19.7,0,0,0,.47-2.85,2.33,2.33,0,0,1,4.29-1.13c2.07,3.2,4.45,7,5.69,9.14a5.24,5.24,0,0,0,2.15,2.1c3.95,2.09,13.58,8,17.45,10.83,2.69,3.22,4.28,11.11,4.5,16.6a10,10,0,0,0,2.94,6.73c2.21,2.18,5.14,5.08,7.12,7.24a6.17,6.17,0,0,1,1.5,5.4c-.84,4-2.46,9.87-4.49,13.13a4.11,4.11,0,0,1-3.36,1.89,68.5,68.5,0,0,1-11.72-1c-5.27-.88-8.78-9.11-12.08-11.52s-7.9-3-11.52-3.84-6.59-4.39-7.79-6.26l1.09-3.18c-3.62-.11-5.81,2-7.13,4.94l2.3-.55s0,3.18.11,6.25,4.83,6.48,5.27,7.8-.66,5.15,0,7.21,4.06,6.07,6.15,8.59c-29.86,7.24-32.49-30.62-28-36.77-2.85,2.22-10.1,7.25-5.05,23.82C10.58,97.75,0,100,0,100V0Zm72.63,64a2.7,2.7,0,0,0,2.41-.06,2.2,2.2,0,0,1-1.1-.25c-1.41-.75-1.72-2.9-.72-4.81,1.37-2.61,5-1.65,5.22-1.33a2.69,2.69,0,0,0-1.31-2c-1.58-.84-3.86.37-5.11,2.71S85.76,78.48,87.34,79.32ZM62.4,60.41A9.2,9.2,0,0,0,66,59.7c4.21-1.77,5.06-5.63,4-9.56h0a11.54,11.54,0,0,0-1.21-3c-2.53-4.81-7.87-7-11.92-4.87s-5.6,8-3.06,12.85a10.11,10.11,0,0,0,1.5,2.14A9.75,9.75,0,0,0,62.4,60.41ZM54.16,59.2c-6.6-6.81-3.35-16.23,1.38-18.89,5.31-3,15.91.13,16.64,9.46.4-8.56-8.14-13-14.14-12.61-1.92-.77-4-5.68-4.57-8.59-1.52,2.76-1.86,5.8-.58,10a35.75,35.75,0,0,1-4-4.83c-.32,2.42,0,8,1.45,9.07C46.3,49.39,50.64,57.38,54.16,59.2Zm-11-11.32c-4-3.65-9.2-6.81-9.88,6.22C34.7,49.34,36.15,44.23,43.13,47.88ZM5.41,41C4.09,42.22,5.13,47.91,8,51.5c-1.27-3.28-1.36-6.91-.21-8s4.76-.73,8,.78c-2.81-2.57-7.16-4-9.3-3.7-.68-2.7-.56-5.28.37-6.15s3.75-.8,6.51.19a15,15,0,0,0,2.27,4.17,15.77,15.77,0,0,1-.93-3.63l.1,0-.1-.09c-.24-2,0-3.65.71-4.32,1.15-1.06,4.77-.72,8,.79-3.38-3.09-9-4.54-10.3-3.31-.8.74-.73,3.1.07,5.64-3.31-2.18-7.51-3.06-8.63-2s-.53,5.34,1.44,8.81A1.46,1.46,0,0,0,5.41,41Z"/><path d="M14.73,35.23l-.1,0v0Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="none" d="M18.52 18.86l-5.77 5.78-5.78-5.78-4.35 4.37L8.39 29l-5.77 5.77 4.35 4.36 5.78-5.77 5.77 5.77 4.37-4.36L17.11 29l5.78-5.77-4.37-4.37z"/>
|
||||||
|
<path d="M100 .11L0 0v100h100L56 55.39l44-39.89zM22.89 34.77l-4.36 4.36-5.77-5.77L7 39.14l-4.39-4.37L8.39 29l-5.78-5.77L7 18.86l5.77 5.77 5.77-5.77 4.36 4.36L17.11 29z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle fill="none" cx="12.52" cy="28.55" r="9.26"/>
|
||||||
|
<path d="M0 100h100L56 55.39l44-39.89V.11L0 0zm12.52-80.71a9.26 9.26 0 1 1-9.26 9.26 9.26 9.26 0 0 1 9.26-9.26z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="Layer_3" data-name="Layer 3"><g id="Layer_8" data-name="Layer 8"><path d="M89.85.15,0,0V99.86c20.33,0,36.5,3.1,49.88-20.14h-10l-5-5-5,5-5-5-5,5-3.59-5L13.1,78A19.93,19.93,0,0,1,1.36,59.8a48.13,48.13,0,0,0,6.22,6.08c5.33,4.35,8.52,4,20,4,17,0,27.79,1.85,46.55-10.3,14.82-9.6,25.6-29.72,25.6-48.27C99.71,5.81,96.11.15,89.85.15ZM29.92,49.55A14.56,14.56,0,1,1,44.48,35,14.56,14.56,0,0,1,29.92,49.55Z"/><path d="M33.14,25.61a10,10,0,1,0,10,10A10,10,0,0,0,33.14,25.61Zm9.35,10A7.49,7.49,0,1,1,35,28.09,7.49,7.49,0,0,1,42.49,35.58Z"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 610 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.dragon-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="dragon-cls-1" d="M35.15,30a24.31,24.31,0,0,0,4.83,5c-.75,1.9-2,5-2,5.2,0,0,3.33-1.74,5.43-2.78a56.81,56.81,0,0,0,5.11,2.78C33.54,50,27,31.5,35.15,30Z"/></g><g id="art"><path d="M79.83,64.38c-3.29,3.55-11.43,2.76-16,4.86a5,5,0,0,0-.13-4.73c-9.72,8.51-23.92,8.15-27.2,6,1.31-7.75,12.09-20.62,16.69-23.12,5.52-.13,12.08,9.2,16.81,9.2s15.38-6.05,17.48-6.48,7.75,1.58,11.69,2.24C98.88,42,79.83,30.61,79.83,30.61S76.15,36,75.62,38.76c-2.36.26-8.54-.79-11.56-1.45,0,0,3.16-4.47.79-8.14-17.61,1.31-25.23-6.31-33.64-13.54C20.05,18.26,15,9.81,12,3.48c-1.67,4.25-2.06,16.62,1.35,20.3C-.85,17.21,7.41,1.81,0,0V100c1.9-5,9.14-24.58,17-31.81-2.89,2.63-2.49,8.41-3.15,12.48a56.38,56.38,0,0,1,10.38-9.2c-2.63,3.68-3.55,13.67-2.36,17.09,5.78,3.28,24.57-7.1,31-7.1S64.59,83,66.56,86.32c-.53,2.1-2.24,4.86-4.21,8,15.9-5,29.7-21.16,30.88-30.22-4.34,3.8-8.8,7.88-15,8.93C78.91,72.26,79.3,69.37,79.83,64.38Zm-.12-25.27c2.37.78,6.56,4.38,7.21,5.3-2.49,1.71-7.35,2.23-8.8,2.23a14.82,14.82,0,0,0,4.81-2.93C82.14,42.53,79.71,39.11,79.71,39.11ZM14.3,40c-1.61,1.1-5.13,4.54-6.3,5.42.52.51,3.52,1.83,6.22,3.22-.8,1.46-2.05,4.16-3.73,7.24,1.1-.37,7.32-1.39,10.47-2.85-1.47,2.78-11.57,6.22-13.91,7.17C7.35,58,9,51.84,10.86,50c-1.61-.3-4.61-2.7-7.17-4.53.66-1.32,5.34-4.83,7.9-6.36a80.58,80.58,0,0,0-6-8.49c9.29-1.54,12.91.53,18.58,3.95A65.34,65.34,0,0,0,9.69,32C9.32,31.93,13.71,37.5,14.3,40ZM35.15,30a24.31,24.31,0,0,0,4.83,5c-.75,1.9-2,5-2,5.2,0,0,3.33-1.74,5.43-2.78a56.81,56.81,0,0,0,5.11,2.78C33.54,50,27,31.5,35.15,30Z"/><path d="M88.13,50.89a8.22,8.22,0,0,0-3.52,1.9,35,35,0,0,1,2.64,8.05c3.51-3.3,4.39-9.34,4.39-9.34A14.36,14.36,0,0,0,88.13,50.89Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M87.78,43.89l9.66,4.83a1.43,1.43,0,0,1,0,2.56L86.38,56.81A28.84,28.84,0,0,0,87.78,43.89Z"/><path d="M18.76,9.38c6.69,24.41-7.57,76.85-16.2,89.34L0,100V0Z"/><path d="M7.16,97.18c11.87-18,22.5-64.71,15.67-86.52-.14-.47-.3-.92-.46-1.36S22,8.38,21.86,8L43,19.21c.1.12.19.26.29.38a11.1,11.1,0,0,1,1.09,1.82C50.9,34.82,42.08,72.49,35.55,83a6.92,6.92,0,0,1-2.35,2.58C32.36,86,14.46,95,7.9,98.3L5.51,99.5c.39-.49.78-1,1.17-1.59C6.84,97.68,7,97.42,7.16,97.18Z"/><path d="M43.72,78.14c7-15.59,11.85-38.61,9.21-51.68-.12-.58-.25-1.15-.4-1.69s-.23-.8-.35-1.18l15.59,7.8c0,.09,0,.19.08.28q.34,1.23.6,2.55c1.91,9.93-.14,23.82-5.16,34.13-.57,1.19-1.18,2.34-1.83,3.42L44,80.52l-1.82.9q.33-.65.66-1.32C43.12,79.46,43.42,78.81,43.72,78.14Z"/><path d="M70.71,63.1c2.9-6.71,5.35-16.22,4.54-23.94-.06-.54-.13-1.08-.23-1.61-.06-.37-.11-.74-.2-1.1l8.39,4.2c0,.09,0,.19.07.28a20.1,20.1,0,0,1,.46,2.48,28.89,28.89,0,0,1-2,14.21,16.29,16.29,0,0,1-2,3.51L71,65.47l-1.83.91c.23-.42.46-.87.68-1.34S70.42,63.78,70.71,63.1Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.duck-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="duck-cls-1" cx="35.25" cy="34.41" rx="8.64" ry="14.75" transform="translate(-10.05 15.06) rotate(-21.19)"/></g><g id="art"><path d="M95.63,26.09a1.84,1.84,0,0,0-1.65-1,1.81,1.81,0,0,0-1.31.56A28.92,28.92,0,0,1,72,34.47a17,17,0,0,1-6.58-1.1,18.68,18.68,0,0,0-1.9-.74,8.65,8.65,0,0,0-2.55-.5c-3.83,0-2.73,4.6-1.7,5.85C67,47.33,55.84,58.06,50,65.24a5.09,5.09,0,0,0,2.59,8.08c1.51.41,3.21.73,5,1,6.95,1.19,15.83,2.37,23.84,9.42a1.62,1.62,0,0,0,1.08.41,2.09,2.09,0,0,0,2-1.72c1.23-7.13-4.37-14.56-16-17.23C95.43,63.82,102.76,40.22,95.63,26.09Z"/><path d="M46.27,70.87a8,8,0,0,1,1.41-7.52c.71-.87,1.46-1.76,2.27-2.71,6.95-8.17,11.88-14.89,7-20.75-1.22-1.47-2.41-5-1.1-7.74a5.36,5.36,0,0,1,5.11-3,9,9,0,0,1,1.66.18C55.37,5.51,31.41,0,0,0V100s5.39-.06,11.79,0c4.57,0,8.73-2.5,11.54-6.1,7-9,31.51-2.61,31.51-17-1.06-.2-2.08-.43-3-.69A8.1,8.1,0,0,1,46.27,70.87Zm-5.69-22.7c-4.45,1.72-10.45-3-13.39-10.64s-1.73-15.15,2.73-16.88,10.44,3,13.39,10.64S45,46.44,40.58,48.17Z"/><path d="M36.21,33.87c-1.86.72-2.37,3.88-1.14,7.05s3.73,5.16,5.59,4.44,2.37-3.88,1.14-7S38.07,33.15,36.21,33.87Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<path d="M91.7,62.4l-1.4,0.7l-9.1-8.5l-6.8,8l-8.1-8h-5.7l5.5-9.7l7.6,9.7l7.6-9.7l7.2,9l0.6,0.7l10.1-7.9l-9.8-17L18.9,6.3
|
||||||
|
l3.6,37.4c9,2,14.6,10.9,12.5,19.9S24.1,78.1,15.1,76.1S0.6,65.1,2.6,56.2c1.3-5.6,5.3-10.1,10.7-12L9.7,3.2L0,0v100l100-15.8V57.9
|
||||||
|
L91.7,62.4z M27.2,24.2c-0.1,0.3-0.4,0.6-0.7,0.6H26c-0.4,0-0.7-0.3-0.7-0.7c0-0.1,0.3-2.6,3.9-5.8c2-1.8,4.4-3.1,7-3.8
|
||||||
|
c0.4-0.1,1.1,0,1.2,0.3s0,1-0.4,1.1c-2.4,0.4-4.6,1.5-6.3,3.1C27.5,21.9,27.2,24.2,27.2,24.2z M39.4,37.8c-5.1,0-9.3-4.1-9.3-9.3
|
||||||
|
s4.1-9.3,9.3-9.3s9.3,4.1,9.3,9.3c0,0,0,0,0,0C48.6,33.7,44.5,37.8,39.4,37.8C39.4,37.8,39.4,37.8,39.4,37.8z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 682 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M71.08,79.45c-3.59,1-50-13.39-50-13.39s5.62,1.25,22.53-10S59.88,67.6,84.83,31.46c0,0,1.17,19.68,1.46,24,5,.3,24.29-29.57,5.88-55.45,0,0-2.32,7.86-16.12,11.56C43,20.41,48.69,0,0,0V100c20.16,0,89.61-.09,96.78-4.18s-5.07-28.66-5.07-28.66S81.32,78.57,71.08,79.45ZM25,27.29a10.8,10.8,0,0,1,7-8.07,11.46,11.46,0,0,0-1,4.46c0,5.6,4.86,10.14,4.86,10.14s4.87-4.54,4.87-10.14a11.08,11.08,0,0,0-.8-4,10.74,10.74,0,1,1-15,7.63Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.egg-cls-1{fill:#fff;}</style></defs><g id="art"><path d="M76.61,26A9.54,9.54,0,1,1,61.87,13.9C48.68,6,29,0,0,0V100c39.19,0,87.8-22.29,87.8-50C87.8,46.32,85.61,36.17,76.61,26Z"/><path class="egg-cls-1" d="M69,29.84A9.55,9.55,0,0,0,76.61,26,61.41,61.41,0,0,0,61.87,13.9,9.54,9.54,0,0,0,69,29.84Z"/><path class="egg-cls-1" d="M18.61,39.93A14.71,14.71,0,1,1,33.32,25.22,14.71,14.71,0,0,1,18.61,39.93Z"/><circle class="egg-cls-1" cx="13.2" cy="73.42" r="6.59"/><path class="egg-cls-1" d="M54.57,68.8a9.55,9.55,0,1,1,9.54-9.54A9.54,9.54,0,0,1,54.57,68.8Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 637 B |
@@ -0,0 +1,2 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 80 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<path d="M100 41V14L0 0v100h74C44 90 32 82 26 64c32-1 55-13 74-23zm-74 0a9 9 0 1 1-13-13c1-1 14 12 13 13zm3-9L6 9l36 22-4 2a7 7 0 0 1-9-1z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M92.11 43.77l-.34-1.42a26.91 26.91 0 0 0-9-14.32l10.68-6.33 1.15 4.82a18.28 18.28 0 0 1-.08 9.67z"/>
|
||||||
|
<path d="M0 100h100L56 55.39l44-39.89V.11L0 0zm21.78-71.45a9.26 9.26 0 1 1-9.26-9.26 9.26 9.26 0 0 1 9.26 9.26z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.ferret-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="ferret-cls-1" d="M33,51.84c3.51,5,13.53,11.12,24.87.8a28.54,28.54,0,0,1-4-3A7.83,7.83,0,1,1,43.68,38.75,15.93,15.93,0,0,1,41,33.4C29.21,32.3,29.5,46.79,33,51.84Z"/></g><g id="art"><path d="M50,12.57C62.28,20.74,69.75,40.2,75.89,43.71c4.92,2.81,16,8.71,20.93,11.19a3.82,3.82,0,0,1,2,2.36,54,54,0,0,0-12-2h0l-.87,0a5,5,0,0,0-4.64,5.19c.27,5.53,2.51,14,6.58,18.48a19,19,0,0,1-11.25,4.85c-6.75.46-10.83-3.07-14.64-2.63S43.39,84.7,37.26,84.3C25.41,83.52,9.6,74.45,3.6,64.93,6.38,75,18.69,80.7,21.76,82.61,17.81,88.17,0,100,0,100V0C27.31,0,41.92,7.2,50,12.57ZM73.71,63.62l-13.83-2a1.5,1.5,0,1,0-.43,3l13.83,2,.22,0a1.5,1.5,0,0,0,.21-3Zm-8.47,15a1.49,1.49,0,0,0,1.34.82,1.44,1.44,0,0,0,.67-.16l6.92-3.48a1.5,1.5,0,1,0-1.35-2.68L65.91,76.6A1.51,1.51,0,0,0,65.24,78.62ZM47,72.73a1.51,1.51,0,0,0,1.49,1.33h.17L73,71.29a1.5,1.5,0,1,0-.34-3L48.32,71.07A1.5,1.5,0,0,0,47,72.73ZM33,51.84c3.51,5,13.53,11.12,24.87.8a28.54,28.54,0,0,1-4-3A7.83,7.83,0,1,1,43.68,38.75,15.93,15.93,0,0,1,41,33.4C29.21,32.3,29.5,46.79,33,51.84ZM3.31,37C4.77,33,6,28.57,7.77,27.4c2.42.22,6.15,1.75,9.3,2.92a58.08,58.08,0,0,0-3.15-7.53,101,101,0,0,1,18,1.39C28.33,18.62,18,13.57,6,11.52,1.85,15,1.41,29.89,3.31,37Z"/><path d="M86.12,58.29l.69,0c3.54,0,9.2,1.37,11.81,2,.78.2,1.3.34,1.38.35s-3.77,15.23-7.58,18.08A7,7,0,0,1,90,76.9c-3.27-3.67-5.45-11.17-5.71-16.57A2,2,0,0,1,86.12,58.29Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.fish-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="fish-cls-1" cx="53.75" cy="49.02" rx="10.04" ry="10.26"/></g><g id="art"><path d="M92.29,58.72C90.75,52.26,78.6,32.55,48.67,22.45l-.07-.13C38,18.75,25.85,17,10.19,16.37c15.6,0,27.08,1.15,37.17,3.49a19.83,19.83,0,0,1,.56-9c-2.29-2.21-8.38-4.34-15.6-6.12l-1.1,9s-2.77-7-4.13-10.22c-3.38-.71-6.86-1.34-10.21-1.87.33,2.66,1.36,10.5,1.36,10.5s-3.54-8.35-4.47-11A111.38,111.38,0,0,0,0,0V100c12.39,0,54.21,3.06,78.73-19.11C71.44,76.06,67.27,69.25,66.61,67.5,68.58,63.76,83.29,58.28,92.29,58.72Zm-58.13-21a1.46,1.46,0,0,1,2.06.21A1.52,1.52,0,0,1,36.06,40c-1.17,1.06-7,13.49.16,21.43A1.51,1.51,0,0,1,35.11,64a1.51,1.51,0,0,1-1.12-.5c-3.17-3.53-4.62-8.25-4.2-13.66C30.23,44.21,32.67,38.9,34.16,37.69Zm-13,11.7c.7-9,4.56-16.55,6.43-18.07a1.5,1.5,0,1,1,1.89,2.33c-1.12.91-4.69,7.81-5.33,16-.4,5.07.23,12.33,5.5,18.19a1.49,1.49,0,0,1-1.12,2.5,1.47,1.47,0,0,1-1.11-.49C22.7,64.55,20.53,57.49,21.17,49.39Zm16.1,35.68c-4-2-11.06-5.46-10.91-5.35s8.27,5.63,8,5.63-8.79.73-8.79.73l11.28,1.56A9.82,9.82,0,0,0,37.74,92c-19.31,5.52-27.6-9.7-23.49-21a3.26,3.26,0,0,1,4.89-1.55c10.34,7.11,16,10.12,23.21,11.07A7.37,7.37,0,0,0,37.27,85.07ZM44.8,49a9,9,0,0,1,17.9-.53,5.43,5.43,0,1,0-3,7.2A9,9,0,0,1,44.8,49ZM59,71c1.62,3.26,5.68,11.49,5.4,11.49s-7.54-7.84-10.77-11.34a2,2,0,0,1,.26-3l12.6-9.65-7.27,10A2.44,2.44,0,0,0,59,71Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M29.85,52.48c-.05-.16-.06-.21-.13-.41A68.56,68.56,0,0,1,26.8,39.38c0-.27-.08-.52-.11-.79l-.27-2.14,2.14-.11a6.3,6.3,0,0,0,5.7-5.19l.54-3.88L37.63,30c2.86,2.74,17,13.82,28.82,13.82,5.54,0,9.6-2.43,12.42-7.44a3.92,3.92,0,0,1,3.42-2h.33a15.9,15.9,0,0,1,8,3.48C78.8,20,61.6,10.15,40.41,10.15c-6.41,0-11.48,1.4-15.49,3.82a23.8,23.8,0,0,0-8.18,8.33c-.39.63-.75,1.28-1.09,1.95a40.2,40.2,0,0,0-3,8.25c-.18.73-.35,1.47-.51,2.21a74.71,74.71,0,0,0-1.36,11.85c-.08,1.63-.11,3.26-.11,4.88,0,.31,0,.62,0,.92a26.51,26.51,0,0,0,3.49,12.12c.39.68.79,1.34,1.23,2A39.77,39.77,0,0,0,27.35,77.74a52,52,0,0,0,26.89,8,48.36,48.36,0,0,0,14.49-2.16A44,44,0,0,1,44.46,74,46.72,46.72,0,0,1,29.85,52.48ZM66.42,37.77c-.74,1.51-4,1.44-7.2-.15S54,33.52,54.69,32s4-1.44,7.2.15S67.15,36.27,66.42,37.77ZM49.18,18.14c.6-1.95,4.48-2.47,8.67-1.18s7.09,3.92,6.49,5.86S59.86,25.29,55.67,24,48.58,20.08,49.18,18.14Zm-21.35.06c.43-2,4.25-2.85,8.53-1.92s7.41,3.29,7,5.28-4.26,2.85-8.54,1.92S27.4,20.19,27.83,18.2ZM43.4,78.79c-.74,1.5-3.15,1.83-5.38.74s-3.44-3.21-2.71-4.71,3.15-1.84,5.38-.74S44.14,77.29,43.4,78.79Z"/><path d="M98.74,50.14a48.79,48.79,0,0,0-4.28-5.83c-3.18-3.71-7.53-7.55-12-7.94h-.16a1.94,1.94,0,0,0-1.68,1C77.11,43.55,72,45.8,66.45,45.8c-12.3,0-26.71-11-30.21-14.38a8.31,8.31,0,0,1-7.57,6.92,69.64,69.64,0,0,0,1.55,8.3c.08.33.17.65.25,1,6.87,25.71,26.66,34.12,41.78,34.12.48,0,1,0,1.42,0,9.88-.35,17.45-4.3,17.45-9.23,0-3.48-3.7-5-8.27-5a24.84,24.84,0,0,0-9.95,2.18,45.25,45.25,0,0,1-7.45.65c-21.92,0-25.39-19.2-27.78-26.56,1.6,1.28,3.19,2.43,4.77,3.52l.69,9.13,7.28-.89,2.2,11.75L62,60.54l5.41,9.6,8.11-8.08,6.6,5.26,6.48-10.08a28.29,28.29,0,0,0,9.69-4.54A2,2,0,0,0,98.74,50.14ZM81.67,64.38l-6.27-5-7.5,7.48-5.22-9.25L54,63.78,52,53.25,45,54.12l-.42-5.49c12,7.74,23,10.25,31.93,10.25a45.41,45.41,0,0,0,9.32-.94Z"/><path d="M12.33,65.91l-.44-.79A27.52,27.52,0,0,1,8.17,51.44c0-.63,0-1.25,0-1.87v-.16a81.91,81.91,0,0,1,1.68-16c0-.16-.18-.32-.15-.47A40.59,40.59,0,0,1,13.85,22c.05-.09.11-.67.16-.76l-7.92-.36a1,1,0,0,1-.91-1.07,1,1,0,0,1,.1-.38l.08-.1a1.25,1.25,0,0,1,.15-.19L5.64,19a1.18,1.18,0,0,1,.19-.11.9.9,0,0,1,.16,0,1.09,1.09,0,0,1,.26,0l6.11.48-4.63-7L2.41,11.13a1,1,0,0,1-.75-1.2,1,1,0,0,1,.16-.34A.34.34,0,0,1,1.9,9.5a.85.85,0,0,1,.17-.16l.12-.07.21-.08.14,0a.87.87,0,0,1,.32,0l3.26.74L3.9,6.59A1,1,0,0,1,4.18,5.2a.91.91,0,0,1,.33-.13l.1,0H5a.93.93,0,0,1,.31.13l.06,0a1.18,1.18,0,0,1,.25.25l5.32,8,2-5.15a.77.77,0,0,1,.19-.3L13.14,8l.2-.14.1,0a.9.9,0,0,1,.27-.07h.07a1,1,0,0,1,.38.07,1,1,0,0,1,.58,1.29l-2.49,6.47,2.67,4a26.58,26.58,0,0,1,8.42-8.15C16.13,1.53,0,0,0,0V100s16.86-1.6,23.81-12.07a25.66,25.66,0,0,0,3.47-7.51C26.1,79.72,25,79,23.84,78.18A40.31,40.31,0,0,1,12.33,65.91ZM1.49,74.05l-.21.44-.11.27L1,75.18l-.09.3c0,.13-.09.27-.12.41s-.06.21-.08.32-.07.28-.1.42L.56,77c0,.14-.06.28-.08.43s0,.21,0,.32L.4,78c.12-2.41.34-4.61.75-5.32a18.18,18.18,0,0,1,6.21-6,34,34,0,0,1-5.12-1.62l-.64-.28,0-.69c0-.36.48-8.67,4.73-14.59C2.12,43.56,1.66,35.26,1.64,34.9l0-.69.64-.28a31.6,31.6,0,0,1,4-1.35,20.87,20.87,0,0,1-5.11-5.22C.8,26.85.6,24.75.46,22.46c0,.35.11.69.19,1l0,.15c.08.35.17.69.28,1,0,0,0,0,0,0a9.31,9.31,0,0,0,.38,1,1.47,1.47,0,0,1,.1.22,8.21,8.21,0,0,0,.52.91A20.43,20.43,0,0,0,8.77,33a33.85,33.85,0,0,0-6.13,1.83s.44,8.87,5,14.64c-4.54,5.76-5,14.64-5,14.64a34,34,0,0,0,7.55,2.08,21.2,21.2,0,0,0-8.21,7c-.14.21-.26.42-.38.63S1.53,74,1.49,74.05ZM17.23,77l-5,7.5,2.49,6.47a1,1,0,0,1-.58,1.29.92.92,0,0,1-.36.07,1,1,0,0,1-.93-.64l-2-5.15-5.32,8a1,1,0,0,1-.84.45,1,1,0,0,1-.83-1.56l2.22-3.33-3.26.74a.83.83,0,0,1-.22,0,1,1,0,0,1-.23-2l5.32-1.22,3.37-5.08L4.77,81.35A1,1,0,0,1,4,80.18a1.16,1.16,0,0,1,.14-.34l.08-.09a1,1,0,0,1,.17-.18l.12-.06a1,1,0,0,1,.21-.09l.14,0a1.07,1.07,0,0,1,.31,0l7,1.36.09,0,3.28-4.93a1,1,0,0,1,.25-.25l.06,0a1.17,1.17,0,0,1,.31-.12h.05a1.41,1.41,0,0,1,.29,0h.1a1.17,1.17,0,0,1,.33.14A1,1,0,0,1,17.23,77Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M98.47,70.71a2,2,0,0,0-.84-1.52,2,2,0,0,0-1.71-.29c-7,2-16.88,3.23-26.35,3.16l1.15-19.65c4.33-2.35,22.82-6.42,23.4-6.6a4.49,4.49,0,0,0,1.57-.72,2.55,2.55,0,0,0,.75-1.53c0-.61,0-2.48-.12-3.62a2,2,0,0,0,1.81-2.07A2,2,0,0,0,95.81,36C94.82,26,80.81,0,50.07,0H0V100H36.86c5.15,0,17.53-8.61,19.28-14.13A80,80,0,0,0,66,87.28a24.61,24.61,0,0,0,2.34,3.28,25.81,25.81,0,0,0,11.22,7.86h0c4.9,1.74,11.19,2.28,18.91.34A2,2,0,0,0,100,96.7ZM47.68,72.89a9,9,0,1,1-5.45-11.47A9,9,0,0,1,47.68,72.89ZM64.91,83.14c-2.33-.23-4.45-.54-6.33-.89a28.6,28.6,0,0,0,1.6-3.71c.33-.94.62-1.91.89-2.88q2.1.2,4.26.3ZM65.57,72c-1.21,0-2.4-.13-3.57-.23.86-4.28,1.35-8.5,2-11.73a7.19,7.19,0,0,1,.3-1.15,9.17,9.17,0,0,1,2.29-3.45Zm.58-57.21c-6.57-3.69-15.85-6.64-24.92-9a48.81,48.81,0,0,1,27.11,6.15c6.48,3.74,15.85,11.74,19.23,21.84A72.4,72.4,0,0,0,66.15,14.74Zm2.76,68.7.43-7.36c3.77,0,7.59-.12,11.29-.45l.69,7.49A78.77,78.77,0,0,1,68.91,83.44Zm12,11.19A22,22,0,0,1,71.4,88L71,87.55a85.84,85.84,0,0,0,10.65-.43l.74,8C81.89,95,81.38,94.81,80.88,94.63ZM84.61,75.2a89.83,89.83,0,0,0,10-1.8l.41,7a75.8,75.8,0,0,1-9.74,2.19Zm1.9,20.66-.85-9.25a80.35,80.35,0,0,0,9.61-2.09l.63,10.72A31.34,31.34,0,0,1,86.51,95.86Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.frog-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="frog-cls-1" d="M35.91,4.65c18.3.08,18.3,27.91,0,28C17.61,32.55,17.61,4.73,35.91,4.65Z"/></g><g id="art"><path d="M83.63,39.48c5-1.13,14.72-.5,14.85-5.45-1.42-8-12.2-11-18.4-13.86C84.5,4.18,61.43-5.74,52.72,8c-8.63-12-29.16-9.16-34.27,4.61C16.52,12.35,2.35.44,0,0V100H43.59c11.55-15.42,13.32-31.32,32-40.47,4.78-2.57,20-6.88,18.11-18.15C71,44,55.54,64.68,33.29,62.6,26,62,15.4,56.86,15.48,47.85h0C33.14,71.38,61.5,44.43,83.63,39.48Zm-5.08-10c1-2.8,8.06,0,6.86,2.76C84.38,35,77.36,32.16,78.55,29.44ZM35.91,4.65c18.3.08,18.3,27.91,0,28C17.61,32.55,17.61,4.73,35.91,4.65Z"/><path d="M32.33,6.58s-9.17,11.58,0,24.12C32.33,30.7,40.39,20.86,32.33,6.58Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 797 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg id="root" viewBox="0 0 100 100"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<style>.gamer-cutout{fill:#ffffff; opacity: 0 }</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<path class="gamer-cutout" d="M55,60.54c-1.89,1.89-3.43,3.43-.58,6.28s4.39,1.31,6.28-.58,2.16-4.71.59-6.28S56.87,58.64,55,60.54Z"/>
|
||||||
|
<path class="gamer-cutout" d="M41.1,80.88h0l14-12.47-2.58-2.9-14,12.42-6.66-7.5h0l-.15-.17A16.64,16.64,0,0,1,29,73l9.21,10.48Z"/>
|
||||||
|
<path d="M94.85,48c3.3-1.52,4.57-3.36,3.43-8.08a24,24,0,0,0-8-12.35c-5.31-4.47-11.93-7-18.52-9.21C54.38,12.7,35.88,12,18.82,6.29l3.63,37.37a16.62,16.62,0,0,1,9.3,26.6l.16.17,6.66,7.5,14-12.42,2.58,2.9-14,12.47-2.91,2.59L29,73a16.63,16.63,0,1,1-15.74-28.8L9.67,3.24-.07,0V100c0-.73,19.59-2.57,21.47-2.75,12-1.19,24.16-.57,36.21-1.49C67.73,95,78.71,94,84.79,84.7c3.28-5,3.78-10.88,3.91-16.72a2.83,2.83,0,0,0-.32-1.68c-.56-.82-1.74-.85-2.74-.79a70.51,70.51,0,0,1-10.07,0c-4.45-.34-9.85-3.64-10.13-8.48a2.77,2.77,0,0,1,.16-1.25,3,3,0,0,1,1.6-1.38c3.12-1.5,7-1,10.38-1.54A83,83,0,0,0,92.2,49.09C93.19,48.73,94.08,48.39,94.85,48ZM39.31,37.8a9.26,9.26,0,1,1,9.26-9.25A9.25,9.25,0,0,1,39.31,37.8ZM60.67,66.24c-1.89,1.89-3.43,3.43-6.28.58s-1.31-4.39.58-6.28,4.71-2.16,6.29-.58S62.57,64.34,60.67,66.24Z"/>
|
||||||
|
<path d="M31.75,70.26,25.34,63a7.22,7.22,0,1,0-2.82,2.68L29,73A16.64,16.64,0,0,0,31.75,70.26Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.ghost-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="ghost-cls-1" d="M43.92,31a23,23,0,0,1,8.34-12.38L64.83,32a22,22,0,0,1-.66,4.29c-2.09,8-8.24,10.94-13.32,9.61S41.83,39,43.92,31Z"/><path class="ghost-cls-1" d="M82.69,46.13c-4.65.6-9.77-2.71-10.71-10a19.21,19.21,0,0,1-.1-3.87L84.54,21.8a20.53,20.53,0,0,1,6,11.91C91.46,41,87.35,45.53,82.69,46.13Z"/></g><g id="art"><path d="M57.18,39.18a3.85,3.85,0,1,0,0-7.69H57.1a2,2,0,0,1,.08.55,2,2,0,0,1-2,2,2,2,0,0,1-1.37-.56,3.83,3.83,0,0,0,3.34,5.72Z"/><path d="M70.72,9C53.34.69,1.59,0,0,0V100c69,0,90.58-10.1,97.94-43C101.27,42.07,99.1,22.51,70.72,9ZM43.92,31a23,23,0,0,1,8.34-12.38L64.83,32a22,22,0,0,1-.66,4.29c-2.09,8-8.24,10.94-13.32,9.61S41.83,39,43.92,31ZM25.24,76.21a5,5,0,0,1-2.1.47c-1.43,0-3.32-.62-5.34-3l-2.62-3.13c-2.13-2.55-2.7-3.23-3.32-3.85l-.28-.3c-.42-.46-1-1.1-4.59,1.26a1.5,1.5,0,1,1-1.65-2.51c4.75-3.12,6.82-2.56,8.45-.77l.19.19c.71.72,1.3,1.43,3.5,4l2.62,3.13c1.38,1.64,2.77,2.29,3.83,1.78A3.59,3.59,0,0,0,25.4,69.6C24,62.7,20.15,52.83,16.13,52.46,10.84,52,4.5,56.55,3.79,57.22a1.51,1.51,0,0,1-2.12-.07A1.49,1.49,0,0,1,1.74,55c1-1,8.17-6.16,14.66-5.56,3.13.29,6,3.19,8.42,8.64A56.81,56.81,0,0,1,28.34,69C29,72,27.62,75.05,25.24,76.21Zm23.82,6c-18.28-6-17.45-27.51-10.56-30.93,4.75-2.35,7.09.41,10.41,3.91l-1.33,6.32a1.19,1.19,0,0,0,1.87,1.21l4.26-3.15a20.47,20.47,0,0,0,5.48,2.84,18.89,18.89,0,0,0,5,1l1.39,4.74a1.19,1.19,0,0,0,2.22.17l2.62-5.53c4.47-1,7.23-2.49,8.71,2.12C81.46,72.19,67.34,88.22,49.06,82.17Zm33.63-36c-4.65.6-9.77-2.71-10.71-10a19.21,19.21,0,0,1-.1-3.87L84.54,21.8a20.53,20.53,0,0,1,6,11.91C91.46,41,87.35,45.53,82.69,46.13Z"/><path d="M93.65,72.44a12,12,0,0,1-5.59,9.06c.62.61,1.82,1.65,5.44,5.18s6.16.65,5.53-2.38C98.5,81.73,97.06,75.26,93.65,72.44Z"/><path d="M78.79,32.67l-.08,0a3.49,3.49,0,0,0-.56,0,2,2,0,0,1,.17,1A2.1,2.1,0,0,1,76,35.56a2,2,0,0,1-1-.41,2.66,2.66,0,0,0-.11.47,3.43,3.43,0,0,0,6.81.87,3.45,3.45,0,0,0-2.89-3.82Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.glasses-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="glasses-cls-1" d="M70.47,39.56a4,4,0,0,0-1,7.87,5.23,5.23,0,0,1-1.14.14,5,5,0,0,1-4.09-2.13h0s-7.2-1.7-10.75-7.08c5.3,2.18,8.89,1.47,8.89,1.47-2.07-1-3.12-5.17-3.12-5.17a13.44,13.44,0,0,0,8.46,2.9c.21,0,.41-.06.62-.06a4.23,4.23,0,0,1,.5.05H69v0a5,5,0,0,1,3.84,2.75A4,4,0,0,0,70.47,39.56Z"/></g><g id="art"><path d="M89.44,36V47.91A4.44,4.44,0,0,1,85,52.35H81.54a4.44,4.44,0,0,1-4.44-4.44V34H26.53a5,5,0,0,0-4.93,3.88,5.61,5.61,0,0,0,3.11,6.42A2.25,2.25,0,0,1,25.8,47a2.13,2.13,0,0,1-2,1.3,2.08,2.08,0,0,1-1-.25,9.87,9.87,0,0,1-5.41-11.21,9.56,9.56,0,0,1,9.36-7.19H77.1V21c0-.13,0-.24,0-.36C60.47,1.06,1.5,0,0,0V100c5.58.09,46.09-1.18,67.76-14.53a1.35,1.35,0,0,0-.58-2.5C45.8,80.7,39.14,67,40,62.15c8.49,2.1,30.94,4.37,45.88,5.69l2.41,8.71a1.6,1.6,0,0,0,3,.32l4.31-8.22L99,68.9C101.48,61.6,97.37,48.46,89.44,36Zm-19,3.59a4,4,0,0,0-1,7.87,5.23,5.23,0,0,1-1.14.14,5,5,0,0,1-4.09-2.13h0s-7.2-1.7-10.75-7.08c5.3,2.18,8.89,1.47,8.89,1.47-2.07-1-3.12-5.17-3.12-5.17a13.44,13.44,0,0,0,8.46,2.9c.21,0,.41-.06.62-.06a4.23,4.23,0,0,1,.5.05H69v0a5,5,0,0,1,3.84,2.75A4,4,0,0,0,70.47,39.56Z"/><path d="M81.54,50.21H85a2.3,2.3,0,0,0,2.3-2.3V21A2.31,2.31,0,0,0,85,18.64H81.54A2.32,2.32,0,0,0,79.23,21v27A2.31,2.31,0,0,0,81.54,50.21Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M83.38,16.84H81.54a3.1,3.1,0,0,1-3.08-3.09L77,3.92C77,2.23,79.76.84,82.46.84s5.46,1.39,5.46,3.08l-1.45,9.83A3.1,3.1,0,0,1,83.38,16.84Z"/><path d="M45.7,16.84H43.86a3.09,3.09,0,0,1-3.08-3.09L39.33,3.92c0-1.69,2.75-3.08,5.45-3.08s5.45,1.39,5.45,3.08l-1.45,9.83A3.09,3.09,0,0,1,45.7,16.84Z"/><path d="M65.2,19.69H63.36a3.1,3.1,0,0,1-3.09-3.08L58.83,6.78c0-1.7,2.75-3.09,5.45-3.09s5.45,1.39,5.45,3.09l-1.45,9.83A3.09,3.09,0,0,1,65.2,19.69Z"/><path d="M81.54,81.65h1.84a3.1,3.1,0,0,1,3.09,3.08l1.45,9.83c0,1.7-2.75,3.09-5.46,3.09S77,96.26,77,94.56l1.45-9.83A3.09,3.09,0,0,1,81.54,81.65Z"/><path d="M43.86,81.65H45.7a3.09,3.09,0,0,1,3.08,3.08l1.45,9.83c0,1.7-2.75,3.09-5.45,3.09s-5.45-1.39-5.45-3.09l1.45-9.83A3.09,3.09,0,0,1,43.86,81.65Z"/><path d="M63.36,78.79H65.2a3.09,3.09,0,0,1,3.08,3.09l1.45,9.83c0,1.7-2.75,3.08-5.45,3.08s-5.45-1.38-5.45-3.08l1.44-9.83A3.1,3.1,0,0,1,63.36,78.79Z"/><path d="M18.16,68.56C15.45,89.45,8,93.65,0,100V67.29H11.19A20.42,20.42,0,0,1,18.16,68.56Z"/><path d="M18.13,29.81a20.13,20.13,0,0,1-6.92,1.26L0,31H0V0C7.94,6.33,15.41,9.49,18.13,29.81Z"/><path d="M99.48,41.54c0-4.64-5.19-5.86-7.07-11a16.33,16.33,0,0,1-1.26-5.69c0-2.28.77-4.46,2.13-8-4.82,2.05-47.75,7.57-61.71,4.5-2,5.21-7.08,9.5-13.08,11.51a24.43,24.43,0,0,1-3.61.9v2.81h0v4.53h0v5.67h0v4.61H15l-.08,1h0V57h0l0,1h0v3.54H15l-.1,1h0v2a24.27,24.27,0,0,1,3.62.91c6,2,11,6.3,13.07,11.51,14-3.08,56.89,2.45,61.71,4.5-1.36-3.58-2.13-5.75-2.13-8a16.23,16.23,0,0,1,1.26-5.68c1.88-5.13,7.07-6.35,7.07-11,0-1.85-.82-4.24-3-7.64C98.66,45.78,99.48,43.39,99.48,41.54ZM40.16,72.8a5.43,5.43,0,1,1,5.42-5.42A5.43,5.43,0,0,1,40.16,72.8Zm0-36.09a5.43,5.43,0,1,1,5.42-5.42A5.43,5.43,0,0,1,40.16,36.71ZM58,72.8a5.43,5.43,0,1,1,5.42-5.42A5.42,5.42,0,0,1,58,72.8Zm0-36.09a5.43,5.43,0,1,1,5.42-5.42A5.42,5.42,0,0,1,58,36.71ZM77,72.8a5.43,5.43,0,1,1,5.43-5.42A5.42,5.42,0,0,1,77,72.8Zm0-36.09a5.43,5.43,0,1,1,5.43-5.42A5.42,5.42,0,0,1,77,36.71Z"/><rect y="34.03" width="11.2" height="30.23"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M0,0V100c35.94,0,58.92-6.47,73.67-14.75-18,1-47.67-8.39-43.71-29.18,9.48-5.56,30.4-4.84,43.32-1.16L71,73.08s9.56-7.55,16-12.75a43.44,43.44,0,0,1,7.8,5.39C100,57,100,50,100,50S100,0,0,0ZM30.65,44.71A17.59,17.59,0,1,1,48.23,27.13,17.58,17.58,0,0,1,30.65,44.71Z"/><path d="M30.65,17.27a9.81,9.81,0,0,0-4,.86A3.55,3.55,0,0,1,28,20.89a3.61,3.61,0,0,1-3.61,3.61,3.55,3.55,0,0,1-2.76-1.36,9.84,9.84,0,1,0,9-5.87Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.horse-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="horse-cls-1" d="M67.54,49.76c-1.16,1.63-3.38,2.78-6.93.24s-6.73-8.77-6.73-8.77a110.49,110.49,0,0,1,11.34,2C69.5,44.07,68.71,48.13,67.54,49.76Z"/></g><g id="art"><path d="M78,52.06c-4.93-5.52-5.5-11.4-7.39-17.15a12.55,12.55,0,0,0-5.27-6.75L48.81,17.81A131.23,131.23,0,0,1,55.54.66C44,1.89,36.33,6.15,31.12,10.83,26.45,6.32,19.18,1.1,10.7,1.45c4,8.39,3.06,14.25,3.06,14.25C10.23,6.88,0,0,0,0V100c6-1.8,17.58-3.67,17.37-35-.22-12.3.68-15,7.5-22.21C21.35,50,21.53,54.25,23,61.28,27.57,82.34,52.17,79.69,58.06,83.5,61.4,85.66,49,98.58,78.16,97.42a19.82,19.82,0,0,0,18.6-17.27C95,66.76,81.76,56.25,78,52.06Zm-10.48-2.3c-1.16,1.63-3.38,2.78-6.93.24s-6.73-8.77-6.73-8.77a110.49,110.49,0,0,1,11.34,2C69.5,44.07,68.71,48.13,67.54,49.76ZM85.38,78.48c-.53,2.48.84,6.27,2.4,8.88C81.74,86.47,80.32,82.12,85.38,78.48Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 955 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.hydra-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="hydra-cls-1" cx="42.48" cy="44.97" rx="4.37" ry="5.39"/><path class="hydra-cls-1" d="M75.03,20.83c-3.54-.61-8.42-4.76-10.49-7.57-.49-5.12,6.96-9.4,10.49,7.57Z"/><path class="hydra-cls-1" d="M74.9,79.95c-.67-2.36-2.36-3.84-5.22-5.28-2.86-1.44-5.92-1.65-8.21-.78,.6-.55,1.32-1.02,2.12-1.38-1.29-.68-2.27-2.1-1.13-3.8,.41,1.51,1.09,2.12,1.87,2.62-.61-1.03-.68-2.33,.67-3.33-.15,2.07,.65,2.81,1.47,3.78,1.35-.1,2.75,.13,4.08,.8,3.22,1.62,4.67,4.46,4.36,7.37Z"/></g><g id="art"><path d="M95.03,91.57c-.89-.73-2.51-.76-3.84-.74-.89,.01-1.83,.12-2.77-.02,5.87-3.61,9.56-8.37,8.42-10.06-1.95-.61-8.67,.01-16.96-10.37-7.69-9.63-16.47-14.76-26.21-2.32-3.36,3.03,1.69,14.83-13.89,14.83-13.62,0-19.8-16.2-31.68-21.7,1.35-7.83,11.09-10.63,18.62-7.49,0,0,0,0,0,0,2.27,17.6,23.43,14.04,24.79,11.97-1.55-.4-10.04-4.92-9.23-9.23,17.45,5.59,24.18-1.21,24.25-6.53-10.64-.34-16.84-14.82-23.71-16.37-4.53-1.02-11.14,1.21-13.31,2.94,0,0,0,0-.01,0-8.86,5.15-17.83,4.89-23.51,3.89,13.48-4.68,20.19-24.03,34.6-24.03,7.02,0,11.7,2.95,15.14,6.27-9.8,11.79,15.79,25.54,18.7,24.82-2.07-2.07-.12-15.98,.61-18.43,0,0,2.93,1.34,4.76,2.2,.37,0,.49,8.05,1.22,9.88,1.71-.73,5.74-7.81,5.74-7.81l4.27,1.22s.61,6.59,.85,8.79c1.83-1.34,5.86-14.15,5.86-17.21-1.34,0-6.34-2.93-9.4-4.15-3.05-1.22-8.66-13.54-15.5-18.06-6.83-4.51-15.74-2.2-19.4,.61,0,0-.21,.26-.54,.67C49.09,2.18,45.1,.19,40.83,.19,29.44,.19,23.1,10.64,17.56,10.64,7.9,10.64,5.16,0,0,0V100c4.88,0,7.09-11.41,16.75-11.41,5.54,0,11.89,10.44,23.27,10.44,6.74,0,12.79-4.93,18.49-10.92,8.55,9.46,19.38,8.11,27.38,4.1,.06,.03,.11,.07,.17,.09,1.48,.68,3.61,.2,4.96,.18,1.27-.02,2.04,.27,2.75,.85,1.42,1.17,2.14,4.93,2.29,6.47l.48-4.27,2.09,3.19c-.02-.19-1.64-5.54-3.6-7.16ZM42.48,39.58c2.42,0,4.37,2.41,4.37,5.39s-1.96,5.39-4.37,5.39-4.37-2.41-4.37-5.39,1.96-5.39,4.37-5.39Zm32.55-18.75c-3.54-.61-8.42-4.76-10.49-7.57-.49-5.12,6.96-9.4,10.49,7.57Zm-4.64,6.96c-.85,3.05-1.22,8.91-.98,10.74-2.81-.98-8.54-9.4-6.22-16.23,0,0,4.64,4.88,7.2,5.49Zm4.5,52.16c-.67-2.36-2.36-3.84-5.22-5.28-2.86-1.44-5.92-1.65-8.21-.78,.6-.55,1.32-1.02,2.12-1.38-1.29-.68-2.27-2.1-1.13-3.8,.41,1.51,1.09,2.12,1.87,2.62-.61-1.03-.68-2.33,.67-3.33-.15,2.07,.65,2.81,1.47,3.78,1.35-.1,2.75,.13,4.08,.8,3.22,1.62,4.67,4.46,4.36,7.37Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M57.7,81.64c12.46,3.66,40,.08,41.8-.07C98,37.79,69.35,0,0,0V100H88.18c6.21,0,11.15-6.68,11.37-12.52C89.36,88.44,63.13,86.51,57.7,81.64ZM49.56,54.69A16.13,16.13,0,1,1,65.68,70.82,16.16,16.16,0,0,1,49.56,54.69Z"/><path d="M73.81,54.69A8,8,0,0,0,63,47.06a3.63,3.63,0,0,1,1.92,3.17A3.63,3.63,0,0,1,58.06,52a8,8,0,0,0-.5,2.72,8.13,8.13,0,1,0,16.25,0Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.judge-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="judge-cls-1" d="M72.85,43.2a3.76,3.76,0,0,0-.94,7.39A4.63,4.63,0,0,1,67,48.72h0S60.27,47.13,57,42.09c5,2,8.32,1.37,8.32,1.37-1.94-.94-2.92-4.85-2.92-4.85a12.52,12.52,0,0,0,7.91,2.73,5.9,5.9,0,0,1,.59-.06,3.8,3.8,0,0,1,.46,0h.16v0a4.69,4.69,0,0,1,3.6,2.6A3.69,3.69,0,0,0,72.85,43.2Z"/></g><g id="art"><path d="M40.49,32.53H77.26l.2,0V25.74a4.16,4.16,0,0,1,.26-1.42c-1.15-1.14-2.34-2.22-3.54-3.25-10.22,6.54-20.08,9-26,9-.47,0-.93,0-1.36,0l-1-.07-.54-.85c-1-1.6-3.15-4.75-5.22-7.78.7,2.11,1.54,4.23,2.3,5.92L43,28.58l-1.12,1A9,9,0,0,0,40.49,32.53Z"/><path d="M81.61,53.2h3.24A2.16,2.16,0,0,0,87,51V25.74a2.17,2.17,0,0,0-2.15-2.17H81.61a2.17,2.17,0,0,0-2.16,2.17V51A2.16,2.16,0,0,0,81.61,53.2Z"/><path d="M89,38V51a4.17,4.17,0,0,1-4.15,4.16H81.61A4.16,4.16,0,0,1,77.46,51V36.51l-.2,0H39C35,50,34.5,70.35,46.57,88.12l.89,1.32-1.57.23c-1.06.16-8.77.74-17.35.74-10,0-19.23-2.46-22.72-5.11a84,84,0,0,0,12.63,2.37A24.65,24.65,0,0,0,18,81.05c1.45,1.14,3.24,5,4,7,1,.08,2,.14,2.93.19.1-2.82.06-8.73-1.59-11.68,2.28,1.28,4.56,9,5.34,11.83l2.07,0c.09-1.92.16-5.69-.39-7.35,1.54,1.21,3.47,5.48,4.16,7.33,3.77-.07,7-.29,9.44-.51C32.66,70.12,33.06,50.11,37,36.53H31.75a4.66,4.66,0,0,0-4.62,3.64,5.32,5.32,0,0,0,3,6.09A2,2,0,0,1,29.23,50a1.9,1.9,0,0,1-.92-.23,9.26,9.26,0,0,1-5-10.59,8.63,8.63,0,0,1,8.49-6.68H38.3a41.73,41.73,0,0,1,2.13-4.8c-.79-1.82-4.72-11.13-3.8-15,0,0,8,11.59,10.36,15.25.39,0,.79,0,1.22,0a33.19,33.19,0,0,0,7.89-1.12,25.76,25.76,0,0,0-3.52-5.59,12.32,12.32,0,0,1,5,5.19,54.05,54.05,0,0,0,5.17-1.78,27.44,27.44,0,0,0-5.52-7.49,15.68,15.68,0,0,1,7.48,6.66q2-.9,4.13-2c-.8-1.84-3.38-5.41-7.57-9,6.3,2.4,9.12,6.19,10,7.59l1.32-.79c1.78-1.12,3.58-2.35,5.36-3.73C63.69,1.25,34.81-.06,20.61-.06L0,0V100c65.33,0,69.34-10.61,73.4-15.8-4.52-6.59-10.49-16.38-12.69-19.45,5.29,1.31,12.73,2.62,19.82,3.68l2.61,9.49a1.51,1.51,0,0,0,2.79.29l4.41-8.43c3.89.48,7.17.82,9.17,1C102,63.62,97.21,50.26,89,38ZM72.85,43.2a3.76,3.76,0,0,0-.94,7.39A4.63,4.63,0,0,1,67,48.72h0S60.27,47.13,57,42.09c5,2,8.32,1.37,8.32,1.37-1.94-.94-2.92-4.85-2.92-4.85a12.52,12.52,0,0,0,7.91,2.73,5.9,5.9,0,0,1,.59-.06,3.8,3.8,0,0,1,.46,0h.16v0a4.69,4.69,0,0,1,3.6,2.6A3.69,3.69,0,0,0,72.85,43.2Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M64.77,55.4l-23.48.43S52.84,54.25,65,52.73C74.71,51.5,84.84,50.31,89.58,50,86.92,32.27,74,22.15,64.85,17.83c-3,1.17-5.93,2.53-8.85,4A139.15,139.15,0,0,0,28.62,40.58a17.17,17.17,0,0,1-5.74,31c5.9,6.84,14.85,13.22,22.84,18.13a189.12,189.12,0,0,0,17.64,9.6C80,96.48,94.72,60.64,96.05,54.83Zm10.88-7.93C66.43,49,44.28,51.52,44.28,51.52S64.44,37.81,70,37.41C75.83,37,79,45.64,75.65,47.47Zm-40-5.86c2.69-5.49,19.7-15.75,28.26-19.48,0,0,3.13.76,2.46,3.14C64.21,25.86,57.71,26.25,35.65,41.61ZM52,82.17A34.89,34.89,0,0,0,56.64,66h6.77S59.79,75.84,52,82.17Zm7.19,3.51C64.9,77.67,67,66,67,66h6.77S68,80.25,59.14,85.68Zm6,3.83C71.66,82,77,66.28,77,66.28h6.77S74.48,84.53,65.19,89.51Z"/><path d="M19,40.64a14.19,14.19,0,0,0-1.88,28.25A14.59,14.59,0,0,0,19,69a14.56,14.56,0,0,0,1.75-.12,14.21,14.21,0,0,0,2.58-27.6A14.42,14.42,0,0,0,19,40.64Zm0,20.67a6.48,6.48,0,1,1,6.48-6.48A6.46,6.46,0,0,1,19,61.31Z"/><path d="M19.36,72,19,72A17.2,17.2,0,1,1,25.8,39,142.72,142.72,0,0,1,54.21,19.41,49.69,49.69,0,0,0,15,0H0V100H15a50.58,50.58,0,0,0,28.08-8.41c-1-.6-1.95-1.23-2.93-1.87Q26.08,80.53,19.36,72ZM4.05,3.24c30.65,0,40.23,10.3,43.2,13.91C28.18,4.72,8.19,6,4.05,6.55Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.ladybug-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="ladybug-cls-1" d="M77,63.72a4.39,4.39,0,1,1,4.39-4.39A4.39,4.39,0,0,1,77,63.72Z"/><path class="ladybug-cls-1" d="M77,45.06a4.39,4.39,0,1,1,4.39-4.39A4.38,4.38,0,0,1,77,45.06Z"/></g><g id="art"><path d="M98.72,66.76l-9.94-6.9a2.89,2.89,0,0,0-1.2-.48A32.44,32.44,0,0,0,89,50a32,32,0,0,0-.59-6.09c.13-.07.26-.13.38-.21l9.94-6.9a3,3,0,0,0,.75-4.16h0a3,3,0,0,0-4.17-.76l-8.66,6c-4.11-10.42-12.75-17.7-18.8-17.7-4.29,0-7.28,3.68-9.16,9.55a37.54,37.54,0,0,0-2.94-11.17l7.76-7.76a3.75,3.75,0,0,0,0-5.28h0a3.75,3.75,0,0,0-5.28,0l-6.88,6.88C40.75,2.28,18.75,0,4.85,0H0V48.5H56.52a1.5,1.5,0,1,1,0,3H0V100H4.85c13.55,0,34.78-2,45.66-11.44l7.71,7.71a3.75,3.75,0,0,0,5.28,0h0a3.75,3.75,0,0,0,0-5.28l-8.28-8.28a35.32,35.32,0,0,0,3.47-12.39c1.88,5.84,4.87,9.49,9.15,9.49,5.51,0,13.16-6,17.59-15l9.87,6.84a3,3,0,0,0,4.17-.75h0A3,3,0,0,0,98.72,66.76ZM13,32.71A10.72,10.72,0,1,1,23.73,22,10.72,10.72,0,0,1,13,32.71Zm29.6,3.68A8.49,8.49,0,1,1,51.1,27.9,8.49,8.49,0,0,1,42.62,36.39ZM13,90.88A10.72,10.72,0,1,1,23.73,80.16,10.72,10.72,0,0,1,13,90.88ZM34.13,74.25a8.49,8.49,0,1,1,8.49,8.49A8.49,8.49,0,0,1,34.13,74.25ZM77,63.72a4.39,4.39,0,1,1,4.39-4.39A4.39,4.39,0,0,1,77,63.72Zm0-18.66a4.39,4.39,0,1,1,4.39-4.39A4.38,4.38,0,0,1,77,45.06Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M88.3,33.55c.88,4.54,1.76,21.68-.58,22.71-.3-6.6-8.21-14.95-8.21-14.95,1.49,15,1.49,20.61.7,24.1-.19-9.23-4.71-14.86-7.07-18,.61,3.13,1.43,13.69-.18,14.4-.2-4.54-5.29-11.12-5.29-11.12a113.49,113.49,0,0,1-1,13.62A57.42,57.42,0,0,0,61,55.72c.29,1.66-1.48,9.42-3.13,13.5-.93.19-1.83.39-2.71.59a61.9,61.9,0,0,0-5-7.43c.21,1.16-.6,5.31-1.67,9.09a80.77,80.77,0,0,0-13,4.42c-2.78-1.32-13.52-21.5,5-29.58,3.09,3.16,6.57,7,7.89,9.23A73.1,73.1,0,0,0,48.05,44a48.82,48.82,0,0,1,5.77,7,77,77,0,0,0-.36-12.28c3.28,3.3,7.3,7.62,8.74,10.09A67.06,67.06,0,0,0,62,38.17c3.48,4,8.36,9.91,10,12.66.63-5.29,0-17.36,0-22.69S74.74,0,0,0V100H57.18C74.61,100,121.71,82.26,88.3,33.55ZM45,10.72a12.1,12.1,0,1,1-12.1,12.1A12.09,12.09,0,0,1,45,10.72Z"/><path d="M45,29.78a7,7,0,1,0-7-7A7,7,0,0,0,45,29.78Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 865 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M92.48,24.51,0,0V100S79.73,87.66,80,87.6c2.38-2.58,18.72-24,19.09-42.88C99.09,44,97.38,26.81,92.48,24.51ZM91,61.58q-1,2.52-2.44,5.31c-1.17.14-22.08,2.89-22.08,2.89L84.86,73.5c-.84,1.36-5.5,8.26-7.12,10.43a84.38,84.38,0,0,1-12.93,1C45.58,84.93,38,79.41,32,72.76a2.77,2.77,0,0,1-.56-2.6c.82-3,5.67-22.29,5.85-23.44.09-.62.79-1.12,2.56-1.27,6.21-.51,18.86-3.93,21.49-4.67,1.8-.5,4.09-1.87,8-4.24C75,33.06,83.61,27.8,88.94,27.8a2.44,2.44,0,0,1,2.61,1.48,59.82,59.82,0,0,1,3.16,12.09c-1.29.15-26.2,3.28-26.2,3.28l26.16,3.2a58.56,58.56,0,0,1-1.49,7.41L55.09,56.93ZM22.53,13.84a9.71,9.71,0,1,1-9.7,9.7A9.7,9.7,0,0,1,22.53,13.84ZM9.22,72.76C4.11,66.94,4.89,59,5.93,53.56c1.21-6.39,8.14-9.63,20.57-9.63h7.83a5.23,5.23,0,0,0-1,2v0c-.13.62-.28,1.3-.44,2H26.5c-7,0-15.64,1.1-16.64,6.38-1.41,7.46-.68,12.33,2.36,15.81S21.38,75.87,30,76.48c.64.71,1.26,1.44,1.95,2.08a30.08,30.08,0,0,0,2.58,2.08C22.09,80.52,13.77,77.93,9.22,72.76Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1005 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M0,0V100c22.37,0,40-2.22,54-6.2-7.16-4.13-13.43-12.66-16.68-24,.86-1.53,7.19-2,8.24-1.54a25.13,25.13,0,0,1-1.32,7.33c.44-.44,3.54-.67,5,.76,1.75,2.07,0,8.58,0,9.82.73-.44,9.6-6.07,13.2-5.36.52,2-1.16,8-6.4,12.39,27.5-8.41,39.66-24,44-42.9C95.52,21.56,68.87,0,0,0ZM68.16,84.31c-1.68-4-.43-16-.43-16s-7.9,6.88-13.62,5.3C50,71.46,49.78,63,50.66,60.73c-1.15,1.81-7.4,4.56-10.47,4.12-2.2-2.93-2.91-12.59-2.91-12.59,3.36,1.9,36.58,18.44,46.39,20.63C83.67,72.89,79,82.19,68.16,84.31ZM57.21,45.39s10.34-3.31,15.29-8c2.23,1.4,4.33,2.82,6.22,4.24a19.61,19.61,0,0,1-16.62,8.7c-10.63,0-19.24-7.71-19.24-17.23a16,16,0,0,1,3.2-9.5c3.13,1.41,7,3.22,11.16,5.3C55.45,35.33,57.21,45.39,57.21,45.39ZM2.6,8.21V2.62c28.3,0,49.48,4.6,63.49,11a50.81,50.81,0,0,1,14.36,9.23,20.5,20.5,0,0,1,5.36,7.84C72.86,15.41,26,8.36,2.6,8.21Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 894 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.mlh-cls-1{fill:#fff;}</style></defs><g id="Layer_1"><g><path d="M88.6,30.7c-1.7-1.3-4.4-2.3-7.8-3.1,.3-6.2,1.1-27.6-4.3-27.6-9.8,0-18.1,17.6-21.1,24.8-.4,0-.8,0-1.2-.1v-3.7c-1.7,1.2-3.8,1.8-6.1,1.8s-4.3-.7-6.1-1.8v3.5c-3.9,0-7.5,0-10.7,.1v-3.8c-1.8,1.3-4,2.1-6.4,2.1-2.1,0-4.1-.6-5.7-1.6v3.7L0-.1V100H44.7c16.1,0,26.8-17.9,33.2-25.7,20,5,22.2-12.2,22.2-18.5,0-6.4,0-16.6-11.5-25.1ZM3.3,31.8c5.3-5.3,20.8,2.3,29.1,10,.6,.5,.6,1.4,.1,2s-1.4,.6-2,.1c-8.8-8-21.9-13.3-25.2-10.1-.4,.4-.8,1.3-.8,2.8h1c5.5,.6,18.8,10.5,21.2,15.9,.2,.5,.2,1.1-.2,1.5l-7.5,9c-.5,.7-1.2,1-1.9,1h-.2c-1.9-.2-4.3-2.8-6.5-6.1-4-4.5-7-10.7-8.2-16.2-.6-2.7-1.3-7.6,1.1-9.9Zm31.3,64.8c-.1,.7-.7,1.3-1.4,1.3H3.7c-.8,0-1.4-.6-1.4-1.4,0-.5-.2-11.1,0-14.1s5.2-7.4,6.2-8.2c1.8-1.5,6.3-4.9,9.6-4.5,5.2,.6,15.4,12.5,16.3,14.5,1.2,2.1,.5,10,.2,12.4Zm-1.2-23c-.5,0-1-.1-1.3-.1-1.5,0-2.2-1.7-2.5-2.3-.6-1.4-1.2-3.9-1.2-6.1,0-1.6,.3-3.1,1.2-4,2.2-2.2,8-1,9.5-.3,.7,.3,1.1,1,1.3,2,.6,2.5,.1,8.4-.8,9.7-.7,.9-2,1.2-3.8,1.2-.6,0-1.5,0-2.4-.1Zm21.5,15.6c-.7,1.3-3.5,2.7-5.8,3.6-3,1.2-4.7,1.5-5.6,1.5-.7,0-1-.2-1.2-.2-.5-.2-1.7-.8-3-6.8-.5-2.1-1.2-5.9-.5-7.3,.9-1.7,8-6.9,10.5-5.6,1.1,.5,2.4,2.9,4,6.9,2.4,6.3,1.8,7.5,1.6,7.9Zm-.3-44.7c-1,1-2.1,1.8-3.2,2.4-.3,7.5-3.4,13.1-7.4,13.1-4.3,0-7.5-6.1-7.5-14.3h0c0-8.1,3.2-14.2,7.5-14.2,3.9,0,6.9,5.1,7.4,12.2,.4-.3,.8-.7,1.2-1,.6-.6,1.5-.6,2,0,.6,.4,.6,1.3,0,1.8Zm1.2,1.2h0q0-.1,0,0c0-8.2,3.2-14.3,7.5-14.3,3.9,0,6.9,5.1,7.4,12.2,.4-.3,.8-.7,1.2-1,.6-.6,1.5-.6,2,0,.6,.6,.6,1.5,0,2-1,1-2.1,1.8-3.2,2.4-.3,7.4-3.4,13-7.4,13-4.3,0-7.5-6.2-7.5-14.3Zm18.5,26.4h0c-5.6-.3-9.3-2.8-11.3-7.3-.3-.7,0-1.6,.7-1.9,.7-.3,1.6,0,1.9,.7,1.6,3.5,4.4,5.3,8.8,5.6,.8,.1,1.4,.7,1.3,1.5,0,.8-.6,1.4-1.4,1.4Zm4.1-45.1c-3.4-.7-7.3-1.2-11.4-1.5,1.5-5.9,5.6-21.3,9.5-21.3,2.4,0,2.1,17.5,1.9,22.8Zm7.9,24h-.2c-.7,0-1.3-.5-1.4-1.3l-.7-5.6c-.1-.8,.5-1.5,1.2-1.6,.8-.1,1.5,.5,1.6,1.2l.7,5.7c.2,.8-.4,1.5-1.2,1.6Zm5.9-1.7c-.1,.8-.7,1.3-1.4,1.3h-.1c-.8-.1-1.4-.7-1.3-1.5l.4-6.1c.1-.8,.7-1.4,1.5-1.3,.8,.1,1.4,.7,1.3,1.5l-.4,6.1Z"/><path d="M33.2,11.7c0,2.4-1,4.6-2.7,6.1-1.5,1.3-3.4,2.1-5.5,2.1s-4.1-.8-5.5-2.1c-1.7-1.5-2.7-3.7-2.7-6.1,0-4.5,3.7-8.2,8.2-8.2s8.2,3.7,8.2,8.2Z"/><path d="M56.5,11.7c0,2.4-1,4.6-2.7,6.1-1.5,1.3-3.4,2.1-5.5,2.1s-4.1-.8-5.5-2.1c-1.7-1.5-2.7-3.7-2.7-6.1,0-4.5,3.7-8.2,8.2-8.2s8.2,3.7,8.2,8.2Z"/><path d="M48.6,44.2c-3.3,.9-6.6,.6-9.2,.2,.3-5.9,2.6-10.2,4.6-10.2s4.2,4.1,4.6,10Z"/><path d="M67.9,44.2c-3.3,.9-6.6,.6-9.2,.2,.3-5.9,2.6-10.2,4.6-10.2s4.2,4.1,4.6,10Z"/></g></g><g id="Layer_3"><path class="mlh-cls-1" d="M3.3,31.8c5.3-5.3,20.8,2.3,29.1,10,.6,.5,.6,1.4,.1,2s-1.4,.6-2,.1c-8.8-8-21.9-13.3-25.2-10.1-.4,.4-.8,1.3-.8,2.8h1c5.5,.6,18.8,10.5,21.2,15.9,.2,.5,.2,1.1-.2,1.5l-7.5,9c-.5,.7-1.2,1-1.9,1h-.2c-1.9-.2-4.3-2.8-6.5-6.1-4-4.5-7-10.7-8.2-16.2-.6-2.7-1.3-7.6,1.1-9.9Z"/><path class="mlh-cls-1" d="M33.4,73.6c-.5,0-1-.1-1.3-.1-1.5,0-2.2-1.7-2.5-2.3-.6-1.4-1.2-3.9-1.2-6.1,0-1.6,.3-3.1,1.2-4,2.2-2.2,8-1,9.5-.3,.7,.3,1.1,1,1.3,2,.6,2.5,.1,8.4-.8,9.7-.7,.9-2,1.2-3.8,1.2-.6,0-1.5,0-2.4-.1Z"/><path class="mlh-cls-1" d="M34.6,96.6c-.1,.7-.7,1.3-1.4,1.3H3.7c-.8,0-1.4-.6-1.4-1.4,0-.5-.2-11.1,0-14.1s5.2-7.4,6.2-8.2c1.8-1.5,6.3-4.9,9.6-4.5,5.2,.6,15.4,12.5,16.3,14.5,1.2,2.1,.5,10,.2,12.4Z"/><path class="mlh-cls-1" d="M54.9,89.2c-.7,1.3-3.5,2.7-5.8,3.6-3,1.2-4.7,1.5-5.6,1.5-.7,0-1-.2-1.2-.2-.5-.2-1.7-.8-3-6.8-.5-2.1-1.2-5.9-.5-7.3,.9-1.7,8-6.9,10.5-5.6,1.1,.5,2.4,2.9,4,6.9,2.4,6.3,1.8,7.5,1.6,7.9Z"/><path class="mlh-cls-1" d="M86.3,51h-.2c-.7,0-1.3-.5-1.4-1.3l-.7-5.6c-.1-.8,.5-1.5,1.2-1.6,.8-.1,1.5,.5,1.6,1.2l.7,5.7c.2,.8-.4,1.5-1.2,1.6Z"/><path class="mlh-cls-1" d="M92.2,49.3c-.1,.8-.7,1.3-1.4,1.3h-.1c-.8-.1-1.4-.7-1.3-1.5l.4-6.1c.1-.8,.7-1.4,1.5-1.3,.8,.1,1.4,.7,1.3,1.5l-.4,6.1Z"/><path class="mlh-cls-1" d="M54.6,44.5c-1,1-2.1,1.8-3.2,2.4-.3,7.5-3.4,13.1-7.4,13.1-4.3,0-7.5-6.1-7.5-14.3h0c0-8.1,3.2-14.2,7.5-14.2,3.9,0,6.9,5.1,7.4,12.2,.4-.3,.8-.7,1.2-1,.6-.6,1.5-.6,2,0,.6,.4,.6,1.3,0,1.8Z"/><path class="mlh-cls-1" d="M55.8,45.7h0q0-.1,0,0c0-8.2,3.2-14.3,7.5-14.3,3.9,0,6.9,5.1,7.4,12.2,.4-.3,.8-.7,1.2-1,.6-.6,1.5-.6,2,0,.6,.6,.6,1.5,0,2-1,1-2.1,1.8-3.2,2.4-.3,7.4-3.4,13-7.4,13-4.3,0-7.5-6.2-7.5-14.3Z"/><path d="M48.6,44.2c-3.3,.9-6.6,.6-9.2,.2,.3-5.9,2.6-10.2,4.6-10.2s4.2,4.1,4.6,10Z"/><path d="M67.9,44.2c-3.3,.9-6.6,.6-9.2,.2,.3-5.9,2.6-10.2,4.6-10.2s4.2,4.1,4.6,10Z"/><path class="mlh-cls-1" d="M78.4,27c-3.4-.7-7.3-1.2-11.4-1.5,1.5-5.9,5.6-21.3,9.5-21.3,2.4,0,2.1,17.5,1.9,22.8Z"/><path class="mlh-cls-1" d="M74.3,72.1h0c-5.6-.3-9.3-2.8-11.3-7.3-.3-.7,0-1.6,.7-1.9,.7-.3,1.6,0,1.9,.7,1.6,3.5,4.4,5.3,8.8,5.6,.8,.1,1.4,.7,1.3,1.5,0,.8-.6,1.4-1.4,1.4Z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 4.6 KiB |