Compare commits
118 Commits
6e74b5fb57
..
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
|
|||
|
3cb3517892
|
|||
|
00d55b5419
|
|||
|
643f4b468e
|
|||
|
332e86e3cc
|
|||
|
066a93f755
|
|||
|
eb290dd634
|
|||
|
2b8f0396e3
|
|||
|
043d7654f9
|
|||
|
a38a600bdc
|
|||
|
9d33c6fded
|
|||
|
79f23b8be6
|
|||
|
6c68f412d2
|
|||
|
92a700409d
|
|||
|
fc8e0657a0
|
|||
|
f8c492f333
|
|||
|
5997a1f6c1
|
|||
|
8564606f4c
|
|||
|
561527a21b
|
|||
|
47051a6068
|
|||
|
c3da096320
|
|||
|
eb6a054bc9
|
|||
|
4daf63d483
|
|||
|
33c8fabc4c
|
|||
|
00d456a412
|
|||
|
65128b25c2
|
|||
|
a1c4a4b68d
|
|||
|
4547e3443b
|
|||
|
bbdc8b288a
|
|||
|
c6ebb5834b
|
|||
|
dbcf9cadaf
|
|||
|
316870ef7a
|
|||
|
b0d484dbab
|
|||
|
9e826afa5f
|
|||
|
d3b0488e0f
|
|||
|
4e9a5595bc
|
|||
|
8f938ce3fe
|
|||
|
dfcdbae85b
|
|||
|
fb579e5fbc
|
|||
|
8f6bc3cfdd
|
|||
|
f124ce6f96
|
|||
|
6ab0161b49
|
|||
|
d7bd89eae9
|
|||
|
eace1872d7
|
|||
|
0a3db6ba57
|
|||
|
013ac98821
|
|||
|
a3fe386198
|
|||
|
8a431da014
|
|||
|
19e4aee454
|
|||
|
c8dd01490c
|
|||
|
1e57ad1af6
|
|||
|
4ca905fbf0
|
|||
|
8e733dfe39
|
|||
|
49f2e0b008
|
|||
|
37de34cc5e
|
|||
|
3d7f92e20f
|
|||
|
2d7a2505d4
|
|||
|
a82eaaaec5
|
|||
|
51de53d01c
|
|||
|
2e1f91355b
|
|||
|
6b69d133b6
|
|||
|
7d52d7dca8
|
|||
|
a885b624f9
|
|||
|
9e3a62d8e8
|
|||
|
9093ca0512
|
@@ -9,26 +9,36 @@ 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@v4
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
token: '${{ secrets.ACTION_ACCESS_TOKEN }}'
|
token: '${{ secrets.ACTION_ACCESS_TOKEN }}'
|
||||||
submodules: recursive
|
submodules: recursive
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.ACTION_ACCESS_TOKEN }}
|
password: ${{ secrets.ACTION_ACCESS_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image for latest tag
|
- name: Build and push Docker image for latest tag
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ vars.DOCKER_REGISTRY_URL }}/daniel156161/battlesnake:latest
|
tags: ${{ vars.DOCKER_REGISTRY_URL }}/daniel156161/battlesnake:latest
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|
||||||
|
- name: Invoke Portainer Stack Deployment
|
||||||
|
if: ${{ vars.PORTAINER_STACK_WEBHOOK_URL && vars.PORTAINER_STACK_WEBHOOK_URL != '' }}
|
||||||
|
uses: distributhor/workflow-webhook@v3
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ vars.PORTAINER_STACK_WEBHOOK_URL }}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
.vscode
|
.vscode
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
data/
|
|
||||||
.env
|
.env
|
||||||
|
.testing/
|
||||||
|
|
||||||
dbschema/migrations/
|
dbschema/migrations/
|
||||||
|
|
||||||
|
*.jsonl
|
||||||
|
/dataset/
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "quart_common"]
|
||||||
|
path = quart_common
|
||||||
|
url = git@git.yiprawr.dev:submodules/python-quart-common.git
|
||||||
|
[submodule "local-client"]
|
||||||
|
path = local-client
|
||||||
|
url = https://github.com/BattlesnakeOfficial/rules.git
|
||||||
@@ -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 apk add --no-cache build-base
|
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
|
||||||
|
|
||||||
# Run Battlesnake
|
EXPOSE 8000
|
||||||
CMD ["uv", "run", "main.py"]
|
|
||||||
|
CMD [".venv/bin/hypercorn", "asgi:app", "--bind", "0.0.0.0:8000", "--workers", "1", "--access-logfile", "-"]
|
||||||
|
|||||||
@@ -54,4 +54,112 @@ battlesnake play -W 11 -H 11 --name 'Python Starter Project' --url http://localh
|
|||||||
|
|
||||||
Continue with the [Battlesnake Quickstart Guide](https://docs.battlesnake.com/quickstart) to customize and improve your Battlesnake's behavior.
|
Continue with the [Battlesnake Quickstart Guide](https://docs.battlesnake.com/quickstart) to customize and improve your Battlesnake's behavior.
|
||||||
|
|
||||||
|
## Included Competitive Snake
|
||||||
|
This repo now includes `snakes/BestBattleSnake.py`, a stronger default snake that combines:
|
||||||
|
- collision and head-to-head risk checks
|
||||||
|
- flood-fill space evaluation to avoid traps
|
||||||
|
- food routing that gets more aggressive as health drops
|
||||||
|
- tail access checks for better long-term survival
|
||||||
|
|
||||||
|
Run it explicitly with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SNAKE=BestBattleSnake python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional duel tuning (when only 2 snakes are alive):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BATTLE_SNAKE_DUEL_STYLE=balanced python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed values: `safe`, `balanced`, `aggressive`.
|
||||||
|
|
||||||
|
## Export Training Dataset
|
||||||
|
Game saves now include a `dataset` section with labeled move samples.
|
||||||
|
|
||||||
|
Export all stored samples to JSONL:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetExporter --input data --output data/dataset/good_moves.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with `just`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just export-dataset
|
||||||
|
```
|
||||||
|
|
||||||
|
Curate a high-quality training subset (single file):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetCurator --input good_moves-2026-04-03.jsonl --output data/dataset/best_moves.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Curate from multiple JSONL sources (repeat `--input`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetCurator \
|
||||||
|
--input good_moves-2026-04-03.jsonl \
|
||||||
|
--input good_moves-2026-04-04.jsonl \
|
||||||
|
--output data/dataset/best_moves.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Curate from folder or glob:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetCurator --input data/dataset --output data/dataset/best_moves.jsonl
|
||||||
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl
|
||||||
|
```
|
||||||
|
|
||||||
|
Append mode (keeps existing curated rows and deduplicates against them):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl --append
|
||||||
|
```
|
||||||
|
|
||||||
|
Archive processed input files after curation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl --append --archive-input
|
||||||
|
python -m server.DatasetCurator --input "good_moves-*.jsonl" --output data/dataset/best_moves.jsonl --append --archive-input --archive-dir data/dataset/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with `just`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just curate-dataset
|
||||||
|
just curate-dataset append=true
|
||||||
|
just curate-dataset append=true archive=true archive_dir=data/dataset/archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Analyze dataset quality overall and by day (best game overall/day included):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m server.DatasetStats --input "good_moves-*.jsonl"
|
||||||
|
python -m server.DatasetStats --input data/dataset --output data/dataset/stats-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The stats report now includes both:
|
||||||
|
- `best_game` (survival/length focused)
|
||||||
|
- `best_pressure_game` (high-pressure quality focused: fewer safe options + strong survival)
|
||||||
|
|
||||||
|
Or with `just`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
just analyze-dataset
|
||||||
|
just analyze-dataset input=data/dataset output=data/dataset/stats-report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
To store compact dataset-only records (JSONL) and skip full per-game JSON files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
STORE_DATASET_ONLY=true DATASET_JSONL_PATH=data/dataset/good_moves.jsonl python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional compact storage tuning:
|
||||||
|
- `DATASET_ROTATE_DAILY=true` creates one JSONL file per day (default: `true`)
|
||||||
|
- `DATASET_JSONL_MAX_MB=50` rotates when file reaches max size in MB (default: `50`)
|
||||||
|
- `DATASET_COMPRESS_ROTATED=true` gzip-compresses rotated/old JSONL files (default: `true`)
|
||||||
|
|
||||||
**Note:** To play games on [play.battlesnake.com](https://play.battlesnake.com) you'll need to deploy your Battlesnake to a live web server OR use a port forwarding tool like [ngrok](https://ngrok.com/) to access your server locally.
|
**Note:** To play games on [play.battlesnake.com](https://play.battlesnake.com) you'll need to deploy your Battlesnake to a live web server OR use a port forwarding tool like [ngrok](https://ngrok.com/) to access your server locally.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from server.bootstrap import build_server_from_env
|
||||||
|
|
||||||
|
server = build_server_from_env(default_snake_type="TemplateSnake")
|
||||||
|
|
||||||
|
app = server.app
|
||||||
@@ -1,661 +0,0 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published
|
|
||||||
by the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<http://www.gnu.org/licenses/>.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# BattlesnakeOfficial/rules
|
|
||||||
|
|
||||||
[](https://codecov.io/gh/BattlesnakeOfficial/rules)
|
|
||||||
|
|
||||||
[Battlesnake](https://play.battlesnake.com) rules and game logic, implemented as a Go module. This code is used in production at [play.battlesnake.com](https://play.battlesnake.com). Issues and contributions welcome!
|
|
||||||
|
|
||||||
|
|
||||||
## CLI for Running Battlesnake Games Locally
|
|
||||||
|
|
||||||
This repo provides a simple CLI tool to run games locally against your dev environment.
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
Download precompiled binaries here: <br>
|
|
||||||
[https://github.com/BattlesnakeOfficial/rules/releases](https://github.com/BattlesnakeOfficial/rules/releases)
|
|
||||||
|
|
||||||
Install as a Go package. Requires Go 1.18 or higher. [[Download](https://golang.org/dl/)]
|
|
||||||
```
|
|
||||||
go install github.com/BattlesnakeOfficial/rules/cli/battlesnake@latest
|
|
||||||
```
|
|
||||||
|
|
||||||
Compile from source. Also requires Go 1.18 or higher.
|
|
||||||
```
|
|
||||||
git clone git@github.com:BattlesnakeOfficial/rules.git
|
|
||||||
cd rules
|
|
||||||
go build -o battlesnake ./cli/battlesnake/main.go
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
Example command to run a game locally:
|
|
||||||
```
|
|
||||||
battlesnake play -W 11 -H 11 --name <SNAKE_NAME> --url <SNAKE_URL> -g solo -v
|
|
||||||
```
|
|
||||||
|
|
||||||
For more details, see the [CLI README](cli/README.md).
|
|
||||||
|
|
||||||
|
|
||||||
## FAQ
|
|
||||||
|
|
||||||
### Can I run games locally?
|
|
||||||
|
|
||||||
Yes! [See the included CLI](cli/README.md).
|
|
||||||
|
|
||||||
### How is this different from the old Battlesnake engine?
|
|
||||||
|
|
||||||
The [old game engine](https://github.com/battlesnakeio/engine) was re-written in early 2020 to handle a higher volume of concurrent games. As part of that rebuild we moved the game logic into a separate Go module that gets compiled into the production engine.
|
|
||||||
|
|
||||||
This provides two benefits: it makes it much simpler/easier to build new game modes, and it allows the community to get more involved in game development (without the maintenance overhead of the entire game engine).
|
|
||||||
|
|
||||||
### Feedback
|
|
||||||
|
|
||||||
* **Do you have an issue or suggestions for this repository?** Head over to our [Feedback Repository](https://play.battlesnake.com/feedback) today and let us know!
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Justfile for Migrate Database Changes Workflow
|
||||||
|
# Docs: https://just.systems/man/en/
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Global settings
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Load Env
|
||||||
|
set dotenv-load
|
||||||
|
set dotenv-required := true
|
||||||
|
|
||||||
|
# Use zsh
|
||||||
|
set shell := ["bash", "-cu"]
|
||||||
|
|
||||||
|
BATTLESNAKE_CLI_DIR := ".testing/tools/battlesnake-cli"
|
||||||
|
BATTLESNAKE_CLI_BIN := ".testing/tools/battlesnake-cli/battlesnake"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Default
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# List all Available recipes
|
||||||
|
[private]
|
||||||
|
default:
|
||||||
|
@just --list --unsorted
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Snake Script helpers
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
run:
|
||||||
|
"{{justfile_directory()}}/main.py"
|
||||||
|
|
||||||
|
run-snake port="8000" snake="BestBattleSnake":
|
||||||
|
HOST="127.0.0.1" PORT="{{port}}" SNAKE="{{snake}}" DEBUG="false" DEBUG_SERVER="false" "{{justfile_directory()}}/main.py"
|
||||||
|
|
||||||
|
run-4-snakes base_port="9101" snake="BestBattleSnake":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
for i in 0 1 2 3; do
|
||||||
|
port="$(({{base_port}} + i))"
|
||||||
|
echo "Starting snake on :$port"
|
||||||
|
HOST="127.0.0.1" PORT="$port" SNAKE="{{snake}}" DEBUG="false" DEBUG_SERVER="false" "{{justfile_directory()}}/main.py" &
|
||||||
|
pids[$i]="$!"
|
||||||
|
done
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
wait || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
wait
|
||||||
|
|
||||||
|
bench-best-snake iterations="1000":
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/tests/bench_best_battle_snake.py" --iterations "{{iterations}}"
|
||||||
|
|
||||||
|
build-battlesnake-cli:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
install_dir="{{justfile_directory()}}/{{BATTLESNAKE_CLI_DIR}}"
|
||||||
|
bin_path="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
||||||
|
mkdir -p "$install_dir"
|
||||||
|
|
||||||
|
if [ ! -f "{{justfile_directory()}}/local-client/go.mod" ]; then
|
||||||
|
echo "Missing local-client source. Run: git submodule update --init --recursive"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
(
|
||||||
|
cd "{{justfile_directory()}}/local-client"
|
||||||
|
go build -o "$bin_path" ./cli/battlesnake/main.go
|
||||||
|
)
|
||||||
|
|
||||||
|
"$bin_path" --help > /dev/null
|
||||||
|
echo "Built Battlesnake CLI at $bin_path"
|
||||||
|
|
||||||
|
battlesnake-cli-version:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
"{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}" --help
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Testing helpers
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test-constrictor: build-battlesnake-cli
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BATTLESNAKE_CLI="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
||||||
|
"$BATTLESNAKE_CLI" play -W 11 -H 11 --name 'Python Starter Project' --url http://localhost:8000 -g constrictor --browser --minimumFood 0
|
||||||
|
|
||||||
|
test-seed: build-battlesnake-cli
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BATTLESNAKE_CLI="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
||||||
|
"$BATTLESNAKE_CLI" play -W 11 -H 11 --name 'Python Starter Project' --url http://localhost:8000 -g solo --browser --seed 1713099635738952360
|
||||||
|
|
||||||
|
test-local-4 mode="standard" map="standard" base_port="9101" snake="BestBattleSnake" seed="1713099635738952360" browser="true": build-battlesnake-cli
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BATTLESNAKE_CLI="{{justfile_directory()}}/{{BATTLESNAKE_CLI_BIN}}"
|
||||||
|
LOG_DIR="{{justfile_directory()}}/.testing/tools/snake-logs"
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
pids=()
|
||||||
|
for i in 0 1 2 3; do
|
||||||
|
port="$(({{base_port}} + i))"
|
||||||
|
log_file="$LOG_DIR/snake-$((i+1)).log"
|
||||||
|
echo "Starting snake-$((i+1)) on :$port (log: $log_file)"
|
||||||
|
HOST="127.0.0.1" PORT="$port" SNAKE="{{snake}}" DEBUG="false" DEBUG_SERVER="false" "{{justfile_directory()}}/main.py" > >(tee "$log_file") 2>&1 &
|
||||||
|
pids[$i]="$!"
|
||||||
|
done
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
for pid in "${pids[@]}"; do
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
wait || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
for i in 0 1 2 3; do
|
||||||
|
port="$(({{base_port}} + i))"
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if curl -fsS "http://127.0.0.1:$port" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
if ! curl -fsS "http://127.0.0.1:$port" >/dev/null 2>&1; then
|
||||||
|
echo "Snake on :$port did not start correctly. Check logs in $LOG_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
BROWSER_FLAG=""
|
||||||
|
if [ "{{browser}}" = "true" ]; then
|
||||||
|
BROWSER_FLAG="--browser"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$BATTLESNAKE_CLI" play -W 11 -H 11 \
|
||||||
|
--name "Snake 1" --url "http://127.0.0.1:{{base_port}}" \
|
||||||
|
--name "Snake 2" --url "http://127.0.0.1:$(({{base_port}} + 1))" \
|
||||||
|
--name "Snake 3" --url "http://127.0.0.1:$(({{base_port}} + 2))" \
|
||||||
|
--name "Snake 4" --url "http://127.0.0.1:$(({{base_port}} + 3))" \
|
||||||
|
-g "{{mode}}" --map "{{map}}" --seed "{{seed}}" $BROWSER_FLAG
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# Dataset helpers
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export-dataset input=".testing/data" output=".testing/data/dataset/good_moves.jsonl":
|
||||||
|
python -m server.DatasetExporter --input "{{input}}" --output "{{output}}"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
run-trained model=".testing/models/battlesnake_softmax_v2.json" port="8000":
|
||||||
|
TRAINED_SNAKE_MODEL="{{model}}" SNAKE="TrainedBattleSnake" PORT="{{port}}" "{{justfile_directory()}}/main.py"
|
||||||
@@ -12,30 +12,25 @@
|
|||||||
# 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 server.CreateEnvironmentFile import CreateEnvironmentFile
|
from dotenv import load_dotenv
|
||||||
from server.Server import Server
|
|
||||||
|
|
||||||
|
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
||||||
|
from server.bootstrap import build_run_config, build_server_from_env
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
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):
|
|
||||||
CreateEnvironmentFile.load_dotenv({"STORE_GAME_HISTORY": True, "DEBUG": True, "SNAKE": "TemplateSnake", "STORE_IF_WIN_AND_MOVES_ARE_BIGGER_AS": 10})
|
|
||||||
|
|
||||||
server = Server(
|
if os.environ.get("CREATE_ENV_FILE", None):
|
||||||
data_path=os.path.dirname(__file__),
|
CreateEnvironmentFile.load_dotenv({
|
||||||
snake_type=os.environ.get("SNAKE", "TemplateSnake"),
|
"STORE_GAME_HISTORY": True,
|
||||||
storage_type=os.environ.get("STORAGE", "LocalStorage"),
|
"DEBUG": True,
|
||||||
store_game_when_win_and_moves_are_bigger_as=int(os.environ.get("STORE_IF_WIN_AND_MOVES_ARE_BIGGER_AS", 10)),
|
"SNAKE": "TemplateSnake",
|
||||||
debug=os.environ.get("DEBUG_SERVER", False),
|
})
|
||||||
check_tls_security=False
|
else:
|
||||||
)
|
load_dotenv()
|
||||||
|
|
||||||
if os.environ.get("STORE_GAME_HISTORY", None):
|
server = build_server_from_env(default_snake_type="TemplateSnake")
|
||||||
server.enable_store_game_state()
|
asyncio.run(server.run(**build_run_config()))
|
||||||
|
|
||||||
server.run(
|
|
||||||
host=os.environ.get("HOST", "0.0.0.0"),
|
|
||||||
port=int(os.environ.get("PORT", "8000")),
|
|
||||||
debug=bool(os.environ.get("DEBUG", False))
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ description = "Add your description here"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aiologger>=0.7.0",
|
||||||
"dotenv>=0.9.9",
|
"dotenv>=0.9.9",
|
||||||
"gel>=3.1.0",
|
"gel>=3.1.0",
|
||||||
|
"redis>=5.2.1",
|
||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
|
"python-dotenv>=1.2.2",
|
||||||
|
"asyncpg>=0.31.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
aiofiles==25.1.0
|
aiofiles==25.1.0
|
||||||
|
aiologger==0.7.0
|
||||||
|
async-timeout==5.0.1
|
||||||
blinker==1.9.0
|
blinker==1.9.0
|
||||||
click==8.3.1
|
click==8.3.2
|
||||||
dotenv==0.9.9
|
dotenv==0.9.9
|
||||||
flask==3.1.2
|
flask==3.1.3
|
||||||
gel==3.1.0
|
gel==3.1.0
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
h2==4.3.0
|
h2==4.3.0
|
||||||
@@ -13,7 +15,9 @@ itsdangerous==2.2.0
|
|||||||
jinja2==3.1.6
|
jinja2==3.1.6
|
||||||
markupsafe==3.0.3
|
markupsafe==3.0.3
|
||||||
priority==2.0.0
|
priority==2.0.0
|
||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.2
|
||||||
quart==0.20.0
|
quart==0.20.0
|
||||||
werkzeug==3.1.4
|
redis==7.4.0
|
||||||
|
typing-extensions==4.15.0
|
||||||
|
werkzeug==3.1.8
|
||||||
wsproto==1.3.2
|
wsproto==1.3.2
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
import aiofiles.os
|
import aiofiles.os
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import os
|
import os
|
||||||
|
import inspect
|
||||||
|
|
||||||
async def read_file(path: str, callback=None):
|
async def read_file(path: str, callback=None):
|
||||||
if not await aiofiles.os.path.exists(path):
|
if not await aiofiles.os.path.exists(path):
|
||||||
@@ -8,7 +9,10 @@ async def read_file(path: str, callback=None):
|
|||||||
|
|
||||||
async with aiofiles.open(path, "r") as f:
|
async with aiofiles.open(path, "r") as f:
|
||||||
if callback:
|
if callback:
|
||||||
return await callback(f)
|
result = callback(f)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
return await result
|
||||||
|
return result
|
||||||
return await f.read()
|
return await f.read()
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +23,8 @@ async def save_file(path: str, data, callback=None, *args, **kwargs):
|
|||||||
|
|
||||||
async with aiofiles.open(path, "w") as f:
|
async with aiofiles.open(path, "w") as f:
|
||||||
if callback:
|
if callback:
|
||||||
await callback(data, f, *args, **kwargs)
|
result = callback(data, f, *args, **kwargs)
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
else:
|
else:
|
||||||
await f.write(data)
|
await f.write(data)
|
||||||
|
|||||||
@@ -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,16 +13,22 @@ 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
|
||||||
|
|
||||||
|
# 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"] ]
|
||||||
|
|
||||||
def _set_my_snake(self, my_snake:str):
|
def _set_my_snake(self, my_snake:dict):
|
||||||
self.my_snake = my_snake
|
self.my_snake = my_snake
|
||||||
|
|
||||||
def _set_food(self, food:list[dict]):
|
def _set_food(self, food:list[dict]):
|
||||||
@@ -61,6 +68,15 @@ class GameBoard:
|
|||||||
def get_type(self):
|
def get_type(self):
|
||||||
return self.type
|
return self.type
|
||||||
|
|
||||||
|
def get_map(self):
|
||||||
|
return self.map
|
||||||
|
|
||||||
|
def get_ruleset(self):
|
||||||
|
return self.ruleset
|
||||||
|
|
||||||
|
def get_timeout(self):
|
||||||
|
return self.timeout
|
||||||
|
|
||||||
def get_my_snake_head(self):
|
def get_my_snake_head(self):
|
||||||
return self.my_snake["head"]
|
return self.my_snake["head"]
|
||||||
|
|
||||||
@@ -79,8 +95,8 @@ class GameBoard:
|
|||||||
"width": self.width,
|
"width": self.width,
|
||||||
"snakes": snakes,
|
"snakes": snakes,
|
||||||
"food": self.food,
|
"food": self.food,
|
||||||
"hazards": self.hazards
|
"hazards": self.hazards,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Game Functions
|
# Game Functions
|
||||||
def read_game_data(self, game_data:dict):
|
def read_game_data(self, game_data:dict):
|
||||||
@@ -91,6 +107,7 @@ class GameBoard:
|
|||||||
self._set_snakes(game_data['board']['snakes'])
|
self._set_snakes(game_data['board']['snakes'])
|
||||||
|
|
||||||
self._set_turn(game_data["turn"])
|
self._set_turn(game_data["turn"])
|
||||||
|
self.timeout = int(game_data.get('game', {}).get('timeout', 500))
|
||||||
|
|
||||||
async def start_game(self, game_data:dict):
|
async def start_game(self, game_data:dict):
|
||||||
self.init_snakes = len(game_data['board']['snakes'])
|
self.init_snakes = len(game_data['board']['snakes'])
|
||||||
@@ -134,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,150 +1,189 @@
|
|||||||
from server.Files import read_file
|
from quart_common.web.logger import build_logger, await_log
|
||||||
from server.GameBoard import GameBoard
|
from quart_common.web.env import env_bool, env_int
|
||||||
from server.SnakeBuilder import SnakeBuilder
|
|
||||||
|
|
||||||
from server.storage.StorageLoader import StorageLoader
|
from snakes import SnakeBuilder
|
||||||
|
|
||||||
from quart import Quart, request, jsonify
|
from server.database import (
|
||||||
import logging, json, os, re
|
GameplayDatabase,
|
||||||
|
GameplayBackendBuilder,
|
||||||
|
StorageLoader,
|
||||||
|
)
|
||||||
|
from server.metrics import (
|
||||||
|
MetricsStoreBuilder,
|
||||||
|
MetricsCollector,
|
||||||
|
)
|
||||||
|
|
||||||
|
import asyncio, signal, logging, os, re, time
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
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 = {"apiversion":"1","author":"","color":"#888888","head":"default","tail":"default"}
|
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):
|
||||||
|
|
||||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, store_game_when_win_and_moves_are_bigger_as:int=10, check_tls_security:bool=False):
|
|
||||||
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
|
||||||
self.store_game_when_win_and_moves_are_bigger_as = store_game_when_win_and_moves_are_bigger_as
|
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.running_games:dict[str, GameBoard] = {}
|
self.game_runtime = GameRuntimeService(
|
||||||
|
snake_type=self.snake_type,
|
||||||
|
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||||
|
)
|
||||||
|
self.dashboard_ws_hub = DashboardWebSocketHub()
|
||||||
|
|
||||||
self.app = Quart("Battlesnake")
|
self.metrics_collector = MetricsCollector(
|
||||||
|
metrics_manager=MetricsStoreBuilder.build(
|
||||||
|
backend=metrics_backend_normalized,
|
||||||
|
redis_url=metrics_redis_url,
|
||||||
|
ttl_seconds=metrics_ttl_sec,
|
||||||
|
key_prefix=os.environ.get('METRICS_REDIS_KEY_PREFIX', 'snake:metrics:worker'),
|
||||||
|
),
|
||||||
|
metrics_backend=metrics_backend_normalized,
|
||||||
|
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||||
|
game_last_seen_unix=self.game_runtime.game_last_seen_unix,
|
||||||
|
game_move_counts=self.game_runtime.game_move_counts,
|
||||||
|
)
|
||||||
|
|
||||||
# info is called when you create your Battlesnake on play.battlesnake.com
|
self.game_runtime.attach_metrics_collector(self.metrics_collector)
|
||||||
# and controls your Battlesnake's appearance
|
self._startup_worker_metrics_cleared = False
|
||||||
# TIP: If you open your Battlesnake URL in a browser you should see this data
|
|
||||||
@self.app.get("/")
|
|
||||||
async def on_info():
|
|
||||||
snake_config = await self._read_json_config_or_create()
|
|
||||||
|
|
||||||
print("INFO Snake:", snake_config)
|
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
||||||
return snake_config
|
self.snake_version = self._get_snake_version()
|
||||||
|
|
||||||
# start is called when your Battlesnake begins a game
|
self.gameplay_database = None
|
||||||
@self.app.post("/start")
|
if gameplay_db_enabled:
|
||||||
async def on_start():
|
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
||||||
game_state = await request.get_json()
|
self.gameplay_database = GameplayDatabase(
|
||||||
await self._create_game_board(game_state)
|
backend=GameplayBackendBuilder.build(
|
||||||
print("GAME START:", game_state["game"])
|
backend=gameplay_db_backend,
|
||||||
return "ok"
|
db_path=db_path,
|
||||||
|
busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||||
|
pg_dsn=gameplay_db_pg_dsn,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# move is called when your Battlesnake game is running game
|
self.gameplay_tracking = GameplayTrackingService(
|
||||||
@self.app.post("/move")
|
gameplay_database=self.gameplay_database,
|
||||||
async def on_move():
|
logger=self.logger,
|
||||||
game_state = await request.get_json()
|
)
|
||||||
game_board = await self._get_game_board(game_state)
|
self.dashboard_query = DashboardQueryService(
|
||||||
next_move = game_board.snake_neat_make_a_move()
|
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)
|
||||||
|
|
||||||
if self.debug:
|
self.app = Quart('Battlesnake', template_folder=os.path.join(data_path, 'templates', 'side'), static_folder=os.path.join(data_path, 'templates', 'files'))
|
||||||
print("TURN:", f'{game_state["turn"]:3},', "MOVE:", f"{next_move:5}")
|
|
||||||
|
|
||||||
return {"move": next_move}
|
self.app.register_blueprint(create_battlesnake_blueprint(self))
|
||||||
|
self.app.register_blueprint(create_metrics_blueprint(self))
|
||||||
# end is called when your Battlesnake finishes a game
|
self.app.register_blueprint(create_dashboard_blueprint(self))
|
||||||
@self.app.post("/end")
|
|
||||||
async def on_end():
|
|
||||||
game_state = await request.get_json()
|
|
||||||
if self.store_game_state:
|
|
||||||
game_board = await self._get_game_board(game_state, end=True)
|
|
||||||
#if not game_board.get_winner() == "me" and not game_board.get_turn() <= self.store_game_when_win_and_moves_are_bigger_as:
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
print("GAME ENDED: Winner is", [ x["name"] for x in game_state["board"]['snakes']])
|
|
||||||
self._delete_game_board(game_state)
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
@self.app.after_request
|
@self.app.after_request
|
||||||
async def identify_server(response):
|
async def identify_server(response):
|
||||||
response.headers.set(
|
response.headers.set('server', 'battlesnake/gitea/snake-python')
|
||||||
"server", "battlesnake/gitea/snake-python"
|
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@self.app.get("/cleanup")
|
@self.app.before_serving
|
||||||
async def cleanup():
|
async def clear_startup_worker_metrics_once():
|
||||||
results = self._cleanup_database()
|
if self._startup_worker_metrics_cleared:
|
||||||
return jsonify(data=json.loads(results), status=200)
|
return
|
||||||
|
self._startup_worker_metrics_cleared = True
|
||||||
|
|
||||||
def run(self, host:str="0.0.0.0", port:str="8000", debug:bool=False):
|
if env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True):
|
||||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300))
|
||||||
|
if should_clear:
|
||||||
|
await self.metrics_collector.clear_worker_metrics()
|
||||||
|
await self.dashboard_events_service.start_listener()
|
||||||
|
|
||||||
print(f"\nRunning Battlesnake at http://{host}:{port} with the {' '.join(re.findall('[A-Z][^A-Z]*', self.snake_type))}")
|
if self.gameplay_database is not None:
|
||||||
self.app.run(host=host, port=port, debug=debug)
|
await self.gameplay_database.initialize()
|
||||||
|
|
||||||
async def _read_json_config_or_create(self):
|
@self.app.after_serving
|
||||||
snake_config = await read_file(self.config_file, json.load)
|
async def shutdown_state_storage():
|
||||||
if not snake_config:
|
await self.dashboard_events_service.stop_listener()
|
||||||
return await self._override_snake_config_with_environment_variables(self.default_snake_config)
|
await self.metrics_collector.close()
|
||||||
return await self._override_snake_config_with_environment_variables(snake_config)
|
if self.gameplay_database is not None:
|
||||||
|
await self.gameplay_database.close()
|
||||||
|
|
||||||
async def _override_snake_config_with_environment_variables(self, config: dict[str, str]) -> dict[str, str]:
|
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
||||||
for key in ("author", "color", "head", "tail"):
|
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||||
value = os.environ.get(f"SNAKE_{key.upper()}")
|
loop = asyncio.get_running_loop()
|
||||||
if value is not None:
|
|
||||||
config[key] = value
|
|
||||||
return config
|
|
||||||
|
|
||||||
async def _create_game_board(self, game_state:dict):
|
installed_signal_handlers:list[signal.Signals] = []
|
||||||
new_game_board = GameBoard(
|
shutdown_event = asyncio.Event()
|
||||||
game_id=game_state["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_state["game"]["id"]] = new_game_board
|
def on_shutdown_signal() -> None:
|
||||||
return new_game_board
|
self.dashboard_ws_hub.request_shutdown()
|
||||||
|
shutdown_event.set()
|
||||||
|
|
||||||
def _delete_game_board(self, game_state):
|
async def shutdown_trigger() -> None:
|
||||||
del self.running_games[game_state["game"]["id"]]
|
await shutdown_event.wait()
|
||||||
|
|
||||||
async def _get_game_board(self, game_state:str, end:bool=False):
|
for shutdown_signal in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(shutdown_signal, on_shutdown_signal)
|
||||||
|
installed_signal_handlers.append(shutdown_signal)
|
||||||
|
except (NotImplementedError, RuntimeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
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))}'))
|
||||||
try:
|
try:
|
||||||
game_board = self.running_games[game_state["game"]["id"]]
|
await self.app.run_task(host=host, port=port, debug=debug, shutdown_trigger=shutdown_trigger)
|
||||||
except KeyError:
|
finally:
|
||||||
game_board = await self._create_game_board(game_state)
|
self.dashboard_ws_hub.request_shutdown()
|
||||||
|
for shutdown_signal in installed_signal_handlers:
|
||||||
|
try:
|
||||||
|
loop.remove_signal_handler(shutdown_signal)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
game_board.read_game_data(game_state)
|
def _get_snake_version(self) -> str:
|
||||||
if end:
|
configured_version = SnakeBuilder.get_version(self.snake_type)
|
||||||
game_board.end_game(game_state)
|
if configured_version is None:
|
||||||
|
return str(SnakeBuilder.get_version('TemplateSnake'))
|
||||||
|
return str(configured_version)
|
||||||
|
|
||||||
return game_board
|
def _get_stale_game_timeout_sec(self) -> int:
|
||||||
|
return max(30, env_int('SNAKE_STUCK_GAME_TIMEOUT_SEC', 180))
|
||||||
|
|
||||||
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 _on_dashboard_games_update_notice(self, trigger:str) -> None:
|
||||||
|
await self.dashboard_query.on_dashboard_games_update_notice(trigger)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
|
|
||||||
class SnakeBuilder:
|
|
||||||
@classmethod
|
|
||||||
def build(self, selected_snake:str):
|
|
||||||
snake_module = __import__(f'snakes.{selected_snake}', fromlist=[selected_snake])
|
|
||||||
snake_class = getattr(snake_module, selected_snake)
|
|
||||||
return snake_class()
|
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import argparse, random, glob, json, math
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MOVES = ["up", "down", "left", "right"]
|
||||||
|
|
||||||
|
def resolve_input_files(inputs:list[str]) -> list[Path]:
|
||||||
|
resolved:list[Path] = []
|
||||||
|
seen:set[str] = set()
|
||||||
|
|
||||||
|
for item in inputs:
|
||||||
|
path = Path(item)
|
||||||
|
if path.is_dir():
|
||||||
|
for file_path in sorted(path.rglob("*.jsonl")):
|
||||||
|
key = str(file_path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
resolved.append(file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(ch in item for ch in "*?[]"):
|
||||||
|
for match in sorted(glob.glob(item)):
|
||||||
|
file_path = Path(match)
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
key = str(file_path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
resolved.append(file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
key = str(path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
resolved.append(path)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def _neighbors(x:int, y:int) -> list[tuple[int, int, str]]:
|
||||||
|
return [
|
||||||
|
(x, y + 1, "up"),
|
||||||
|
(x, y - 1, "down"),
|
||||||
|
(x - 1, y, "left"),
|
||||||
|
(x + 1, y, "right"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _safe_neighbor_count(point:tuple[int, int], blocked:set[tuple[int, int]], width:int, height:int) -> int:
|
||||||
|
count = 0
|
||||||
|
for nx, ny, _ in _neighbors(point[0], point[1]):
|
||||||
|
if not (0 <= nx < width and 0 <= ny < height):
|
||||||
|
continue
|
||||||
|
if (nx, ny) in blocked:
|
||||||
|
continue
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _manhattan_to_nearest_food(point: tuple[int, int], food: set[tuple[int, int]]) -> int:
|
||||||
|
if not food:
|
||||||
|
return 25
|
||||||
|
return min(abs(point[0] - fx) + abs(point[1] - fy) for fx, fy in food)
|
||||||
|
|
||||||
|
def extract_feature_values(row:dict) -> dict[str, float]:
|
||||||
|
board = row.get("game_board", {})
|
||||||
|
snakes = board.get("snakes", [])
|
||||||
|
if not snakes:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
me = snakes[0]
|
||||||
|
body = me.get("body", [])
|
||||||
|
if not body:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
width = int(board.get("width", 0))
|
||||||
|
height = int(board.get("height", 0))
|
||||||
|
head = body[0]
|
||||||
|
hx = int(head.get("x", 0))
|
||||||
|
hy = int(head.get("y", 0))
|
||||||
|
health = int(me.get("health", 100))
|
||||||
|
length = int(me.get("length", len(body)))
|
||||||
|
|
||||||
|
food_set = {(int(f.get("x", 0)), int(f.get("y", 0))) for f in board.get("food", [])}
|
||||||
|
hazard_set = {(int(h.get("x", 0)), int(h.get("y", 0))) for h in board.get("hazards", [])}
|
||||||
|
blocked = set()
|
||||||
|
for snake in snakes:
|
||||||
|
for seg in snake.get("body", []):
|
||||||
|
blocked.add((int(seg.get("x", 0)), int(seg.get("y", 0))))
|
||||||
|
|
||||||
|
features:dict[str, float] = {
|
||||||
|
"bias": 1.0,
|
||||||
|
"health_norm": max(0.0, min(1.0, health / 100.0)),
|
||||||
|
"length_norm": min(1.0, length / max(1.0, width * height)),
|
||||||
|
"turn_norm": min(1.0, int(row.get("turn", 0)) / 100.0),
|
||||||
|
"food_count_norm": min(1.0, len(food_set) / 10.0),
|
||||||
|
"hazard_count_norm": min(1.0, len(hazard_set) / 20.0),
|
||||||
|
"opponent_count_norm": min(1.0, max(0, len(snakes) - 1) / 7.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
safe_total = 0
|
||||||
|
for nx, ny, move in _neighbors(hx, hy):
|
||||||
|
in_bounds = 1.0 if (0 <= nx < width and 0 <= ny < height) else 0.0
|
||||||
|
blocked_next = 1.0 if (nx, ny) in blocked else 0.0
|
||||||
|
food_next = 1.0 if (nx, ny) in food_set else 0.0
|
||||||
|
hazard_next = 1.0 if (nx, ny) in hazard_set else 0.0
|
||||||
|
|
||||||
|
if in_bounds and not blocked_next:
|
||||||
|
safe_total += 1
|
||||||
|
open_next = float(_safe_neighbor_count((nx, ny), blocked, width, height))
|
||||||
|
dist_food = float(_manhattan_to_nearest_food((nx, ny), food_set))
|
||||||
|
else:
|
||||||
|
open_next = 0.0
|
||||||
|
dist_food = 25.0
|
||||||
|
|
||||||
|
prefix = f"m:{move}:"
|
||||||
|
features[prefix + "in_bounds"] = in_bounds
|
||||||
|
features[prefix + "blocked"] = blocked_next
|
||||||
|
features[prefix + "food"] = food_next
|
||||||
|
features[prefix + "hazard"] = hazard_next
|
||||||
|
features[prefix + "open_next"] = min(4.0, open_next) / 4.0
|
||||||
|
features[prefix + "food_dist"] = min(25.0, dist_food) / 25.0
|
||||||
|
|
||||||
|
features["safe_total_norm"] = safe_total / 4.0
|
||||||
|
return features
|
||||||
|
|
||||||
|
class SoftmaxMoveModel:
|
||||||
|
def __init__(self):
|
||||||
|
self.weights = {move: {} for move in MOVES}
|
||||||
|
self.bias = {move: 0.0 for move in MOVES}
|
||||||
|
|
||||||
|
def _score(self, move:str, features:dict[str, float]) -> float:
|
||||||
|
weight_map = self.weights[move]
|
||||||
|
value = self.bias[move]
|
||||||
|
for name, feat in features.items():
|
||||||
|
value += weight_map.get(name, 0.0) * feat
|
||||||
|
return value
|
||||||
|
|
||||||
|
def fit(self, rows:list[dict], epochs:int=14, lr:float=0.08, l2:float=1e-6) -> None:
|
||||||
|
examples = []
|
||||||
|
for row in rows:
|
||||||
|
label = row.get("move")
|
||||||
|
if label not in MOVES:
|
||||||
|
continue
|
||||||
|
features = extract_feature_values(row)
|
||||||
|
if not features:
|
||||||
|
continue
|
||||||
|
examples.append((features, label))
|
||||||
|
|
||||||
|
if not examples:
|
||||||
|
return
|
||||||
|
|
||||||
|
for _ in range(epochs):
|
||||||
|
random.shuffle(examples)
|
||||||
|
for features, label in examples:
|
||||||
|
scores = {move: self._score(move, features) for move in MOVES}
|
||||||
|
max_score = max(scores.values())
|
||||||
|
exp_scores = {
|
||||||
|
move: math.exp(scores[move] - max_score) for move in MOVES
|
||||||
|
}
|
||||||
|
z = sum(exp_scores.values())
|
||||||
|
probs = {move: exp_scores[move] / z for move in MOVES}
|
||||||
|
|
||||||
|
for move in MOVES:
|
||||||
|
target = 1.0 if move == label else 0.0
|
||||||
|
gradient = target - probs[move]
|
||||||
|
self.bias[move] += lr * gradient
|
||||||
|
|
||||||
|
w = self.weights[move]
|
||||||
|
for name, feat in features.items():
|
||||||
|
current = w.get(name, 0.0)
|
||||||
|
update = lr * ((gradient * feat) - (l2 * current))
|
||||||
|
w[name] = current + update
|
||||||
|
|
||||||
|
def predict_scores(self, row:dict) -> dict[str, float]:
|
||||||
|
features = extract_feature_values(row)
|
||||||
|
if not features:
|
||||||
|
return {move: 0.0 for move in MOVES}
|
||||||
|
return {move: self._score(move, features) for move in MOVES}
|
||||||
|
|
||||||
|
def predict(self, row:dict) -> str:
|
||||||
|
scores = self.predict_scores(row)
|
||||||
|
return max(scores, key=lambda move: scores[move])
|
||||||
|
|
||||||
|
def evaluate(self, rows:list[dict]) -> dict:
|
||||||
|
total = 0
|
||||||
|
correct = 0
|
||||||
|
top2 = 0
|
||||||
|
confusion = {move: Counter() for move in MOVES}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
expected = row.get("move")
|
||||||
|
if expected not in MOVES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
scores = self.predict_scores(row)
|
||||||
|
ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True)
|
||||||
|
predicted = ranked[0][0]
|
||||||
|
|
||||||
|
total += 1
|
||||||
|
if predicted == expected:
|
||||||
|
correct += 1
|
||||||
|
if expected in {
|
||||||
|
ranked[0][0],
|
||||||
|
ranked[1][0] if len(ranked) > 1 else ranked[0][0],
|
||||||
|
}:
|
||||||
|
top2 += 1
|
||||||
|
|
||||||
|
confusion[expected][predicted] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"correct": correct,
|
||||||
|
"accuracy": round((correct / total) if total else 0.0, 4),
|
||||||
|
"top2_accuracy": round((top2 / total) if total else 0.0, 4),
|
||||||
|
"confusion": {label: dict(confusion[label]) for label in MOVES},
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"model_type": "softmax_moves_v2",
|
||||||
|
"moves": MOVES,
|
||||||
|
"weights": self.weights,
|
||||||
|
"bias": self.bias,
|
||||||
|
}
|
||||||
|
|
||||||
|
def read_rows(paths:list[Path]) -> list[dict]:
|
||||||
|
rows: list[dict] = []
|
||||||
|
for path in paths:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
row = json.loads(line)
|
||||||
|
if row.get("move") not in MOVES:
|
||||||
|
continue
|
||||||
|
if not row.get("game_board"):
|
||||||
|
continue
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Train Battlesnake move model")
|
||||||
|
parser.add_argument("--input", action="append", required=True)
|
||||||
|
parser.add_argument("--output", required=True)
|
||||||
|
parser.add_argument("--eval-split", type=float, default=0.2)
|
||||||
|
parser.add_argument("--seed", type=int, default=42)
|
||||||
|
parser.add_argument("--epochs", type=int, default=14)
|
||||||
|
parser.add_argument("--lr", type=float, default=0.08)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
paths = resolve_input_files(args.input)
|
||||||
|
if not paths:
|
||||||
|
raise SystemExit("No input files found")
|
||||||
|
|
||||||
|
rows = read_rows(paths)
|
||||||
|
if len(rows) < 50:
|
||||||
|
raise SystemExit("Need at least 50 rows for training")
|
||||||
|
|
||||||
|
random.seed(args.seed)
|
||||||
|
random.shuffle(rows)
|
||||||
|
eval_count = int(len(rows) * max(0.0, min(0.5, args.eval_split)))
|
||||||
|
eval_rows = rows[:eval_count]
|
||||||
|
train_rows = rows[eval_count:]
|
||||||
|
|
||||||
|
model = SoftmaxMoveModel()
|
||||||
|
model.fit(train_rows, epochs=max(1, args.epochs), lr=max(1e-4, args.lr))
|
||||||
|
metrics = model.evaluate(eval_rows)
|
||||||
|
|
||||||
|
output = Path(args.output)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {
|
||||||
|
"input_files": [str(p) for p in paths],
|
||||||
|
"train_rows": len(train_rows),
|
||||||
|
"eval_rows": len(eval_rows),
|
||||||
|
"eval_metrics": metrics,
|
||||||
|
"model": model.to_dict(),
|
||||||
|
}
|
||||||
|
output.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"output": str(output),
|
||||||
|
"train_rows": len(train_rows),
|
||||||
|
"eval_rows": len(eval_rows),
|
||||||
|
"accuracy": metrics.get("accuracy"),
|
||||||
|
"top2_accuracy": metrics.get("top2_accuracy"),
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from typing import TypedDict
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
from quart_common.web.env import env_bool, env_int
|
||||||
|
from server.Server import Server
|
||||||
|
|
||||||
|
class RunConfig(TypedDict):
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
debug: bool
|
||||||
|
|
||||||
|
def build_server_from_env(default_snake_type:str) -> Server:
|
||||||
|
data_path = str(Path(__file__).resolve().parent.parent)
|
||||||
|
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||||
|
|
||||||
|
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
||||||
|
if metrics_backend is None:
|
||||||
|
metrics_backend = os.environ.get('BACKEND', 'memory')
|
||||||
|
|
||||||
|
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 = env_int('METRICS_TTL_SEC', 900) if metrics_ttl_sec_raw is not None else None
|
||||||
|
|
||||||
|
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
|
||||||
|
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(
|
||||||
|
data_path=data_path,
|
||||||
|
snake_type=os.environ.get('SNAKE', default_snake_type),
|
||||||
|
storage_type=os.environ.get('STORAGE', 'LocalStorage'),
|
||||||
|
debug=env_bool('DEBUG_SERVER'),
|
||||||
|
check_tls_security=False,
|
||||||
|
metrics_backend=metrics_backend,
|
||||||
|
metrics_redis_url=metrics_redis_url,
|
||||||
|
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'):
|
||||||
|
server.enable_store_game_state()
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
|
def build_run_config() -> RunConfig:
|
||||||
|
return {
|
||||||
|
'host': os.environ.get('HOST', '0.0.0.0'),
|
||||||
|
'port': env_int('PORT', 8000),
|
||||||
|
'debug': env_bool('DEBUG'),
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
from server.GameBoard import GameBoard
|
from server.GameBoard import GameBoard
|
||||||
|
from server.dataset.Dataset import Dataset
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import gel, json, time
|
import json, time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import gel as _gel # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_gel = None
|
||||||
|
|
||||||
class EdgeDB:
|
class EdgeDB:
|
||||||
def __init__(self, database:str=None, tls_security:str='insecure', **kwargs):
|
def __init__(self, database:str=None, tls_security:str='insecure', **kwargs):
|
||||||
@@ -10,16 +16,20 @@ class EdgeDB:
|
|||||||
self._connect()
|
self._connect()
|
||||||
|
|
||||||
def _connect(self):
|
def _connect(self):
|
||||||
self.client = gel.create_client(
|
if _gel is None:
|
||||||
tls_security=self.tls_security,
|
raise ImportError("The 'gel' package is required to use EdgeDB storage")
|
||||||
database=self.database
|
self.client = _gel.create_client(
|
||||||
|
tls_security=self.tls_security, database=self.database
|
||||||
)
|
)
|
||||||
|
|
||||||
def run_query_with_reconnection(self, function, *args, **kwargs):
|
def run_query_with_reconnection(self, function, *args, **kwargs):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
return function(*args, **kwargs)
|
return function(*args, **kwargs)
|
||||||
except gel.errors.ClientConnectionFailedError:
|
except Exception as error:
|
||||||
|
if error.__class__.__name__ != "ClientConnectionFailedError":
|
||||||
|
raise
|
||||||
|
|
||||||
self._connect()
|
self._connect()
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
@@ -39,9 +49,21 @@ class EdgeDB:
|
|||||||
data = []
|
data = []
|
||||||
moves = game_board.turns
|
moves = game_board.turns
|
||||||
snake_calulations = [[calc for calc in ele["data"]] for ele in game_board.snake_class.get_history() ]
|
snake_calulations = [[calc for calc in ele["data"]] for ele in game_board.snake_class.get_history() ]
|
||||||
|
labels_by_turn = Dataset(game_board).labels_by_turn()
|
||||||
|
|
||||||
for i in range(len(moves)):
|
for i in range(len(moves)):
|
||||||
data.append({"turn": moves[i]["turn"], "move": moves[i]["move"], "game_board": moves[i]["game_board"], "calculations": snake_calulations[i]})
|
calculations = snake_calulations[i] if i < len(snake_calulations) else []
|
||||||
|
calculations.append({
|
||||||
|
"dataset": {
|
||||||
|
"is_good_move": labels_by_turn.get(moves[i]["turn"], False)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.append({
|
||||||
|
"turn": moves[i]["turn"],
|
||||||
|
"move": moves[i]["move"],
|
||||||
|
"game_board": moves[i]["game_board"],
|
||||||
|
"calculations": calculations,
|
||||||
|
})
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@@ -91,9 +113,20 @@ class EdgeDB:
|
|||||||
created_at=datetime.fromtimestamp(game_board.now_date.timestamp(), game_board.now_date.astimezone().tzinfo),
|
created_at=datetime.fromtimestamp(game_board.now_date.timestamp(), game_board.now_date.astimezone().tzinfo),
|
||||||
turns=game_board.turn,
|
turns=game_board.turn,
|
||||||
map=game_board.map if game_board.map else "standard",
|
map=game_board.map if game_board.map else "standard",
|
||||||
winner=', '.join(game_board.winner_snake_names) if game_board.winner_snake_names else "",
|
winner=', '.join(game_board.winner_snake_names)
|
||||||
moves=[ tuple([x["turn"], x["move"], json.dumps(x["game_board"]), [ json.dumps(ele) for ele in x["calculations"] ] ]) for x in self.create_moves_with_calculations(game_board) ],
|
if game_board.winner_snake_names
|
||||||
|
else "",
|
||||||
|
moves=[
|
||||||
|
tuple(
|
||||||
|
[
|
||||||
|
x["turn"],
|
||||||
|
x["move"],
|
||||||
|
json.dumps(x["game_board"]),
|
||||||
|
[json.dumps(ele) for ele in x["calculations"]],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for x in self.create_moves_with_calculations(game_board)
|
||||||
|
],
|
||||||
game_type=game_type["name"],
|
game_type=game_type["name"],
|
||||||
is_ladder=game_type["is_ladder"],
|
is_ladder=game_type["is_ladder"],
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
from server.dataset.Dataset import Dataset
|
||||||
|
from server.Files import save_file
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
import aiofiles.os
|
||||||
|
import gzip, json, os
|
||||||
|
|
||||||
|
class LocalStorage:
|
||||||
|
def __init__(self, file_path:str, **kwargs):
|
||||||
|
self.save_folder_dict = {
|
||||||
|
"standard": "01_Standard",
|
||||||
|
"duel": "02_Duels",
|
||||||
|
"constrictor": "04_Constrictor",
|
||||||
|
"solo": "05_Solo",
|
||||||
|
}
|
||||||
|
self.file_path = file_path
|
||||||
|
self.dataset_only = os.getenv("STORE_DATASET_ONLY", "false").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
self.dataset_jsonl_path = os.getenv("DATASET_JSONL_PATH", os.path.join(self.file_path, "dataset", "good_moves.jsonl"))
|
||||||
|
self.dataset_rotate_daily = os.getenv("DATASET_ROTATE_DAILY", "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)
|
||||||
|
|
||||||
|
def _get_active_dataset_path(self, game_board:'GameBoard'):
|
||||||
|
if not self.dataset_rotate_daily:
|
||||||
|
return self.dataset_jsonl_path
|
||||||
|
|
||||||
|
base, ext = os.path.splitext(self.dataset_jsonl_path)
|
||||||
|
if ext == "":
|
||||||
|
ext = ".jsonl"
|
||||||
|
return f"{base}-{game_board.now_date.strftime('%Y-%m-%d')}{ext}"
|
||||||
|
|
||||||
|
def _gzip_file(self, file_path:str):
|
||||||
|
gz_path = f"{file_path}.gz"
|
||||||
|
with open(file_path, "rb") as src:
|
||||||
|
with gzip.open(gz_path, "wb") as dst:
|
||||||
|
dst.writelines(src)
|
||||||
|
os.remove(file_path)
|
||||||
|
|
||||||
|
async def _compress_old_daily_files(self, active_path:str):
|
||||||
|
if not self.dataset_compress_rotated:
|
||||||
|
return
|
||||||
|
|
||||||
|
folder = os.path.dirname(active_path)
|
||||||
|
base_name = os.path.basename(self.dataset_jsonl_path)
|
||||||
|
base_stem, _ = os.path.splitext(base_name)
|
||||||
|
prefix = f"{base_stem}-"
|
||||||
|
active_name = os.path.basename(active_path)
|
||||||
|
|
||||||
|
if folder == "" or not await aiofiles.os.path.exists(folder):
|
||||||
|
return
|
||||||
|
|
||||||
|
for name in os.listdir(folder):
|
||||||
|
if name == active_name:
|
||||||
|
continue
|
||||||
|
if not name.startswith(prefix):
|
||||||
|
continue
|
||||||
|
if not name.endswith(".jsonl"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._gzip_file(os.path.join(folder, name))
|
||||||
|
|
||||||
|
async def _rotate_if_needed(self, active_path:str, game_board:'GameBoard'):
|
||||||
|
if self.dataset_max_bytes <= 0:
|
||||||
|
return
|
||||||
|
if not await aiofiles.os.path.exists(active_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
file_size = (await aiofiles.os.stat(active_path)).st_size
|
||||||
|
if file_size < self.dataset_max_bytes:
|
||||||
|
return
|
||||||
|
|
||||||
|
timestamp = game_board.now_date.strftime("%Y%m%d-%H%M%S")
|
||||||
|
rotated_path = f"{active_path}.{timestamp}.jsonl"
|
||||||
|
suffix = 1
|
||||||
|
while await aiofiles.os.path.exists(rotated_path):
|
||||||
|
suffix += 1
|
||||||
|
rotated_path = f"{active_path}.{timestamp}.{suffix}.jsonl"
|
||||||
|
|
||||||
|
await aiofiles.os.rename(active_path, rotated_path)
|
||||||
|
if self.dataset_compress_rotated:
|
||||||
|
self._gzip_file(rotated_path)
|
||||||
|
|
||||||
|
def _build_dataset_rows(self, dataset_payload:dict, game_board:'GameBoard'):
|
||||||
|
game_info = dataset_payload.get("game", {})
|
||||||
|
snake_info = dataset_payload.get("snake", {})
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for sample in dataset_payload.get("samples", []):
|
||||||
|
rows.append({
|
||||||
|
"game_id": game_info.get("id", game_board.id),
|
||||||
|
"game_map": game_info.get("map", game_board.map),
|
||||||
|
"game_type": game_info.get("type", game_board.get_type_of_game()),
|
||||||
|
"snake_type": snake_info.get(
|
||||||
|
"type", game_board.snake_class.__class__.__name__
|
||||||
|
),
|
||||||
|
"turn": sample.get("turn"),
|
||||||
|
"move": sample.get("move"),
|
||||||
|
"is_good_move": sample.get("is_good_move", False),
|
||||||
|
"game_board": sample.get("game_board"),
|
||||||
|
"history": sample.get("history"),
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def _append_dataset_jsonl(self, dataset_payload:dict, game_board:'GameBoard'):
|
||||||
|
rows = self._build_dataset_rows(dataset_payload, game_board)
|
||||||
|
if len(rows) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
active_path = self._get_active_dataset_path(game_board)
|
||||||
|
await aiofiles.os.makedirs(os.path.dirname(active_path), exist_ok=True)
|
||||||
|
await self._compress_old_daily_files(active_path)
|
||||||
|
await self._rotate_if_needed(active_path, game_board)
|
||||||
|
|
||||||
|
async with aiofiles.open(active_path, "a") as f:
|
||||||
|
for row in rows:
|
||||||
|
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):
|
||||||
|
storage_folder = self.file_path
|
||||||
|
if leader_board:
|
||||||
|
storage_folder = os.path.join(storage_folder, "00_Leaderboards")
|
||||||
|
|
||||||
|
storage_folder = os.path.join(storage_folder, self.save_folder_dict[game_type])
|
||||||
|
storage_folder = os.path.join(
|
||||||
|
storage_folder,
|
||||||
|
game_board.now_date.strftime("%Y"),
|
||||||
|
game_board.now_date.strftime("%m_%B"),
|
||||||
|
game_board.now_date.strftime("%d"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if winner:
|
||||||
|
storage_folder = os.path.join(storage_folder, "Winner")
|
||||||
|
else:
|
||||||
|
storage_folder = os.path.join(storage_folder, "Lost")
|
||||||
|
|
||||||
|
return os.path.join(storage_folder, file_name)
|
||||||
|
|
||||||
|
async def save(self, game_board:'GameBoard'):
|
||||||
|
game_type = game_board.get_type_of_game()
|
||||||
|
dataset = Dataset(game_board).build(only_good_moves=True)
|
||||||
|
|
||||||
|
await self._append_dataset_jsonl(dataset, game_board)
|
||||||
|
if self.dataset_only:
|
||||||
|
return
|
||||||
|
|
||||||
|
save_file_path = self._get_correct_folder_for_save_file(
|
||||||
|
game_board,
|
||||||
|
f"{game_board.snake_class.__class__.__name__}_{game_board.now_date.strftime('%H-%M-%S')}_{game_board.id}.json",
|
||||||
|
game_type["name"],
|
||||||
|
game_type["is_ladder"],
|
||||||
|
True if game_board.winner_snake_names and "me" in game_board.winner_snake_names else False
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"winner": game_board.winner_snake_names,
|
||||||
|
"game": {
|
||||||
|
"url": game_board.url,
|
||||||
|
"id": game_board.id,
|
||||||
|
"final_turns": game_board.turn,
|
||||||
|
"map": game_board.map,
|
||||||
|
"type": game_type,
|
||||||
|
"ruleset": game_board.ruleset,
|
||||||
|
},
|
||||||
|
"moves": game_board.turns,
|
||||||
|
"snake": {
|
||||||
|
"type": game_board.snake_class.__class__.__name__,
|
||||||
|
"calculations": game_board.snake_class.get_history(),
|
||||||
|
},
|
||||||
|
"dataset": dataset,
|
||||||
|
}
|
||||||
|
|
||||||
|
await save_file(save_file_path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
pass
|
||||||
@@ -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'.")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from server.GameBoard import GameBoard
|
||||||
|
|
||||||
|
class Dataset:
|
||||||
|
VALID_MOVES = {"up", "down", "left", "right"}
|
||||||
|
|
||||||
|
def __init__(self, game_board:'GameBoard'):
|
||||||
|
self.game_board = game_board
|
||||||
|
|
||||||
|
def _did_we_win(self):
|
||||||
|
winners = self.game_board.winner_snake_names or []
|
||||||
|
return "me" in winners
|
||||||
|
|
||||||
|
def _is_good_move(self, move:str):
|
||||||
|
return move in self.VALID_MOVES
|
||||||
|
|
||||||
|
def build(self, only_good_moves:bool=True):
|
||||||
|
game_type = self.game_board.get_type_of_game()
|
||||||
|
did_win = self._did_we_win()
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
history = self.game_board.snake_class.get_history()
|
||||||
|
for index, turn in enumerate(self.game_board.turns):
|
||||||
|
move = turn.get("move")
|
||||||
|
is_good_move = did_win and self._is_good_move(move)
|
||||||
|
if only_good_moves and not is_good_move:
|
||||||
|
continue
|
||||||
|
|
||||||
|
samples.append({
|
||||||
|
"turn": turn.get("turn"),
|
||||||
|
"move": move,
|
||||||
|
"game_board": turn.get("game_board"),
|
||||||
|
"is_good_move": is_good_move,
|
||||||
|
"history": history[index] if index < len(history) else {},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"game": {
|
||||||
|
"id": self.game_board.id,
|
||||||
|
"map": self.game_board.map,
|
||||||
|
"type": game_type,
|
||||||
|
},
|
||||||
|
"snake": {
|
||||||
|
"type": self.game_board.snake_class.__class__.__name__,
|
||||||
|
},
|
||||||
|
"did_win": did_win,
|
||||||
|
"total_samples": len(samples),
|
||||||
|
"samples": samples,
|
||||||
|
}
|
||||||
|
|
||||||
|
def labels_by_turn(self):
|
||||||
|
did_win = self._did_we_win()
|
||||||
|
labels = {}
|
||||||
|
for turn in self.game_board.turns:
|
||||||
|
move = turn.get("move")
|
||||||
|
labels[turn.get("turn")] = did_win and self._is_good_move(move)
|
||||||
|
return labels
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import argparse, hashlib, shutil, json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.dataset.DatasetIO import DatasetIO
|
||||||
|
|
||||||
|
class DatasetCurator:
|
||||||
|
def __init__(self, input_files:list[str], output_file:str, min_turn:int=6, late_turn:int=20, max_safe_options:int=2, min_score:int=3, append:bool=False, archive_input:bool=False, archive_dir:str|None=None):
|
||||||
|
self.input_files = input_files
|
||||||
|
self.output_file = Path(output_file)
|
||||||
|
self.min_turn = min_turn
|
||||||
|
self.late_turn = late_turn
|
||||||
|
self.max_safe_options = max_safe_options
|
||||||
|
self.min_score = min_score
|
||||||
|
self.append = append
|
||||||
|
self.archive_input = archive_input
|
||||||
|
self.archive_dir = Path(archive_dir) if archive_dir else self.output_file.parent / "archive"
|
||||||
|
|
||||||
|
def _safe_options_count(self, row:dict):
|
||||||
|
history = row.get("history", {})
|
||||||
|
for item in history.get("data", []):
|
||||||
|
if item.get("function") == "get_possible_moves":
|
||||||
|
return len(item.get("safe_positions", {}))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _state_hash(self, row:dict):
|
||||||
|
board = row.get("game_board", {})
|
||||||
|
snakes = board.get("snakes", [])
|
||||||
|
|
||||||
|
snakes_key = []
|
||||||
|
for snake in snakes:
|
||||||
|
snakes_key.append((
|
||||||
|
snake.get("id"),
|
||||||
|
snake.get("health"),
|
||||||
|
tuple((seg.get("x"), seg.get("y")) for seg in snake.get("body", [])),
|
||||||
|
))
|
||||||
|
|
||||||
|
key = {
|
||||||
|
"width": board.get("width"),
|
||||||
|
"height": board.get("height"),
|
||||||
|
"snakes": sorted(snakes_key),
|
||||||
|
"food": sorted((f.get("x"), f.get("y")) for f in board.get("food", [])),
|
||||||
|
"hazards": sorted((h.get("x"), h.get("y")) for h in board.get("hazards", [])),
|
||||||
|
}
|
||||||
|
raw = json.dumps(key, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _score(self, row:dict):
|
||||||
|
score = 0
|
||||||
|
turn = int(row.get("turn", 0))
|
||||||
|
safe_options = self._safe_options_count(row)
|
||||||
|
snakes = row.get("game_board", {}).get("snakes", [])
|
||||||
|
opponents = max(0, len(snakes) - 1)
|
||||||
|
|
||||||
|
if turn >= self.late_turn:
|
||||||
|
score += 2
|
||||||
|
if safe_options is not None and safe_options <= self.max_safe_options:
|
||||||
|
score += 3
|
||||||
|
if opponents >= 1:
|
||||||
|
score += 1
|
||||||
|
|
||||||
|
return score, safe_options
|
||||||
|
|
||||||
|
def curate(self):
|
||||||
|
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
input_paths = DatasetIO.resolve_input_files(self.input_files)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
kept = 0
|
||||||
|
skipped_turn = 0
|
||||||
|
skipped_quality = 0
|
||||||
|
skipped_duplicate = 0
|
||||||
|
seen_states = set()
|
||||||
|
|
||||||
|
if self.append and self.output_file.exists():
|
||||||
|
for row in DatasetIO.iter_jsonl_rows(self.output_file):
|
||||||
|
state_key = self._state_hash(row)
|
||||||
|
seen_states.add((state_key, row.get("move")))
|
||||||
|
|
||||||
|
mode = "a" if self.append else "w"
|
||||||
|
with DatasetIO.open_text(self.output_file, mode) as dst:
|
||||||
|
for input_path in input_paths:
|
||||||
|
for row in DatasetIO.iter_jsonl_rows(input_path):
|
||||||
|
total += 1
|
||||||
|
|
||||||
|
if not row.get("is_good_move", False):
|
||||||
|
skipped_quality += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if int(row.get("turn", 0)) < self.min_turn:
|
||||||
|
skipped_turn += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
quality_score, safe_options = self._score(row)
|
||||||
|
if quality_score < self.min_score:
|
||||||
|
skipped_quality += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
state_key = self._state_hash(row)
|
||||||
|
dedupe_key = (state_key, row.get("move"))
|
||||||
|
if dedupe_key in seen_states:
|
||||||
|
skipped_duplicate += 1
|
||||||
|
continue
|
||||||
|
seen_states.add(dedupe_key)
|
||||||
|
|
||||||
|
compact_row = {
|
||||||
|
"game_id": row.get("game_id"),
|
||||||
|
"turn": row.get("turn"),
|
||||||
|
"move": row.get("move"),
|
||||||
|
"game_type": row.get("game_type"),
|
||||||
|
"quality_score": quality_score,
|
||||||
|
"safe_options": safe_options,
|
||||||
|
"game_board": row.get("game_board"),
|
||||||
|
}
|
||||||
|
dst.write(json.dumps(compact_row, ensure_ascii=False) + "\n")
|
||||||
|
kept += 1
|
||||||
|
|
||||||
|
archived_files = []
|
||||||
|
if self.archive_input:
|
||||||
|
archived_files = self._archive_processed_files(input_paths)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"input_files": [str(path) for path in input_paths],
|
||||||
|
"total_rows": total,
|
||||||
|
"kept_rows": kept,
|
||||||
|
"skipped_turn": skipped_turn,
|
||||||
|
"skipped_quality": skipped_quality,
|
||||||
|
"skipped_duplicate": skipped_duplicate,
|
||||||
|
"append_mode": self.append,
|
||||||
|
"archive_input": self.archive_input,
|
||||||
|
"archived_files": archived_files,
|
||||||
|
"output_file": str(self.output_file),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _archive_processed_files(self, input_paths:list[Path]):
|
||||||
|
self.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archived = []
|
||||||
|
|
||||||
|
output_resolved = (
|
||||||
|
self.output_file.resolve()
|
||||||
|
if self.output_file.exists()
|
||||||
|
else self.output_file
|
||||||
|
)
|
||||||
|
archive_resolved = self.archive_dir.resolve()
|
||||||
|
|
||||||
|
for source_path in input_paths:
|
||||||
|
if not source_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_resolved = source_path.resolve()
|
||||||
|
if source_resolved == output_resolved:
|
||||||
|
continue
|
||||||
|
if source_resolved.parent == archive_resolved:
|
||||||
|
continue
|
||||||
|
|
||||||
|
destination = self.archive_dir / source_path.name
|
||||||
|
if destination.exists():
|
||||||
|
stem = destination.stem
|
||||||
|
suffix = destination.suffix
|
||||||
|
index = 1
|
||||||
|
while True:
|
||||||
|
candidate = self.archive_dir / f"{stem}.{index}{suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
destination = candidate
|
||||||
|
break
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
shutil.move(str(source_path), str(destination))
|
||||||
|
archived.append(str(destination))
|
||||||
|
|
||||||
|
return archived
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Create curated best-moves dataset")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
action="append",
|
||||||
|
required=True,
|
||||||
|
help="Input JSONL/JSONL.GZ file, directory, or glob pattern. Repeat for multiple inputs.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", required=True, help="Output JSONL file")
|
||||||
|
parser.add_argument("--min-turn", type=int, default=6)
|
||||||
|
parser.add_argument("--late-turn", type=int, default=20)
|
||||||
|
parser.add_argument("--max-safe-options", type=int, default=2)
|
||||||
|
parser.add_argument("--min-score", type=int, default=3)
|
||||||
|
parser.add_argument(
|
||||||
|
"--append",
|
||||||
|
action="store_true",
|
||||||
|
help="Append to existing output and dedupe against existing rows",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--archive-input",
|
||||||
|
action="store_true",
|
||||||
|
help="Move processed input files to archive directory after successful curation",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--archive-dir",
|
||||||
|
default=None,
|
||||||
|
help="Archive directory for processed input files (default: <output-dir>/archive)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report = DatasetCurator(
|
||||||
|
input_files=args.input,
|
||||||
|
output_file=args.output,
|
||||||
|
min_turn=args.min_turn,
|
||||||
|
late_turn=args.late_turn,
|
||||||
|
max_safe_options=args.max_safe_options,
|
||||||
|
min_score=args.min_score,
|
||||||
|
append=args.append,
|
||||||
|
archive_input=args.archive_input,
|
||||||
|
archive_dir=args.archive_dir,
|
||||||
|
).curate()
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import argparse, json
|
||||||
|
|
||||||
|
from server.dataset.DatasetIO import DatasetIO
|
||||||
|
|
||||||
|
class DatasetExporter:
|
||||||
|
def __init__(self, input_dir:str, output_file:str):
|
||||||
|
self.input_dir = Path(input_dir)
|
||||||
|
self.output_file = Path(output_file)
|
||||||
|
|
||||||
|
def _iter_game_files(self):
|
||||||
|
return DatasetIO.list_directory_files(self.input_dir, directory_pattern="*.json")
|
||||||
|
|
||||||
|
def _extract_samples(self, payload:dict, source_file:Path):
|
||||||
|
dataset = payload.get("dataset", {})
|
||||||
|
game_info = dataset.get("game", payload.get("game", {}))
|
||||||
|
snake_info = dataset.get("snake", payload.get("snake", {}))
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
for sample in dataset.get("samples", []):
|
||||||
|
samples.append({
|
||||||
|
"game_id": game_info.get("id"),
|
||||||
|
"game_map": game_info.get("map"),
|
||||||
|
"game_type": game_info.get("type"),
|
||||||
|
"snake_type": snake_info.get("type"),
|
||||||
|
"turn": sample.get("turn"),
|
||||||
|
"move": sample.get("move"),
|
||||||
|
"is_good_move": sample.get("is_good_move", False),
|
||||||
|
"game_board": sample.get("game_board"),
|
||||||
|
"history": sample.get("history"),
|
||||||
|
"source_file": str(source_file),
|
||||||
|
})
|
||||||
|
return samples
|
||||||
|
|
||||||
|
def export_jsonl(self):
|
||||||
|
game_files = self._iter_game_files()
|
||||||
|
self.output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
sample_count = 0
|
||||||
|
with DatasetIO.open_text(self.output_file, "w") as output:
|
||||||
|
for game_file in game_files:
|
||||||
|
with game_file.open("r", encoding="utf-8") as source:
|
||||||
|
payload = json.load(source)
|
||||||
|
|
||||||
|
for sample in self._extract_samples(payload, game_file):
|
||||||
|
output.write(json.dumps(sample, ensure_ascii=False) + "\n")
|
||||||
|
sample_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"games_scanned": len(game_files),
|
||||||
|
"samples_exported": sample_count,
|
||||||
|
"output_file": str(self.output_file),
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Export Battlesnake dataset to JSONL")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input", default="data", help="Input directory with stored game JSON files"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", default="data/dataset/good_moves.jsonl", help="Output JSONL file"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report = DatasetExporter(args.input, args.output).export_jsonl()
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from typing import Any, TextIO, cast
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import gzip, glob, json
|
||||||
|
|
||||||
|
class DatasetIO:
|
||||||
|
@staticmethod
|
||||||
|
def resolve_input_files(input_files: list[str], directory_pattern: str | tuple[str, ...] = ("*.jsonl", "*.jsonl.gz")) -> list[Path]:
|
||||||
|
patterns = (
|
||||||
|
(directory_pattern,)
|
||||||
|
if isinstance(directory_pattern, str)
|
||||||
|
else directory_pattern
|
||||||
|
)
|
||||||
|
resolved = []
|
||||||
|
seen = set()
|
||||||
|
|
||||||
|
for item in input_files:
|
||||||
|
path = Path(item)
|
||||||
|
if path.is_dir():
|
||||||
|
for pattern in patterns:
|
||||||
|
for file_path in sorted(path.rglob(pattern)):
|
||||||
|
key = str(file_path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
resolved.append(file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(ch in item for ch in "*?[]"):
|
||||||
|
for match in sorted(glob.glob(item)):
|
||||||
|
file_path = Path(match)
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
key = str(file_path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
resolved.append(file_path)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if path.is_file():
|
||||||
|
key = str(path.resolve())
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
resolved.append(path)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def list_directory_files(input_dir:Path, directory_pattern:str) -> list[Path]:
|
||||||
|
if not input_dir.exists():
|
||||||
|
return []
|
||||||
|
return sorted(input_dir.rglob(directory_pattern))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def open_text(path:Path, mode:str="r"):
|
||||||
|
text_mode = mode if "t" in mode else f"{mode}t"
|
||||||
|
open_fn = gzip.open if path.suffix == ".gz" else open
|
||||||
|
return cast(TextIO, open_fn(path, text_mode, encoding="utf-8"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def iter_jsonl_rows(path:Path):
|
||||||
|
with DatasetIO.open_text(path, "r") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if line.strip():
|
||||||
|
yield json.loads(line)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def append_jsonl_row(path:Path, row:dict[str, Any]):
|
||||||
|
with DatasetIO.open_text(path, "a") as raw_handle:
|
||||||
|
handle = cast(TextIO, raw_handle)
|
||||||
|
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_jsonl_rows(path:Path) -> int:
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
candidates = [path]
|
||||||
|
if path.suffix == ".gz":
|
||||||
|
candidates.append(path.with_suffix(""))
|
||||||
|
else:
|
||||||
|
candidates.append(Path(f"{path}.gz"))
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate in seen:
|
||||||
|
continue
|
||||||
|
seen.add(candidate)
|
||||||
|
|
||||||
|
if not candidate.exists() or not candidate.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
with DatasetIO.open_text(candidate, "r") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if line.strip():
|
||||||
|
count += 1
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def rotate_and_gzip_if_size_reached(path:Path, max_bytes:int) -> bool:
|
||||||
|
if max_bytes <= 0:
|
||||||
|
return False
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
return False
|
||||||
|
if path.stat().st_size < max_bytes:
|
||||||
|
return False
|
||||||
|
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
|
if path.suffix == ".jsonl":
|
||||||
|
rotated_name = f"{path.stem}.{timestamp}.jsonl"
|
||||||
|
else:
|
||||||
|
rotated_name = f"{path.name}.{timestamp}"
|
||||||
|
|
||||||
|
rotated_path = path.with_name(rotated_name)
|
||||||
|
suffix = 1
|
||||||
|
while rotated_path.exists():
|
||||||
|
suffix += 1
|
||||||
|
if path.suffix == ".jsonl":
|
||||||
|
rotated_name = f"{path.stem}.{timestamp}.{suffix}.jsonl"
|
||||||
|
else:
|
||||||
|
rotated_name = f"{path.name}.{timestamp}.{suffix}"
|
||||||
|
rotated_path = path.with_name(rotated_name)
|
||||||
|
|
||||||
|
path.rename(rotated_path)
|
||||||
|
with rotated_path.open("rb") as src:
|
||||||
|
with gzip.open(f"{rotated_path}.gz", "wb") as dst:
|
||||||
|
dst.writelines(src)
|
||||||
|
rotated_path.unlink()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
from collections import Counter, defaultdict
|
||||||
|
import argparse, json, re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from server.dataset.DatasetIO import DatasetIO
|
||||||
|
|
||||||
|
class DatasetStats:
|
||||||
|
DAY_PATTERN = re.compile(r"(\d{4}-\d{2}-\d{2})")
|
||||||
|
|
||||||
|
def __init__(self, input_files:list[str]):
|
||||||
|
self.input_files = input_files
|
||||||
|
|
||||||
|
def _infer_day(self, file_path:Path):
|
||||||
|
match = self.DAY_PATTERN.search(file_path.name)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return datetime.fromtimestamp(file_path.stat().st_mtime).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
def _game_score(self, game:dict):
|
||||||
|
max_turn = game["max_turn"]
|
||||||
|
rows = game["rows"]
|
||||||
|
avg_safe = game["avg_safe_options"]
|
||||||
|
pressure_bonus = 0 if avg_safe is None else max(0.0, 4.0 - avg_safe)
|
||||||
|
return round(max_turn * 2.0 + rows + pressure_bonus, 3)
|
||||||
|
|
||||||
|
def _pressure_score(self, game:dict):
|
||||||
|
max_turn = game["max_turn"]
|
||||||
|
rows = max(1, game["rows"])
|
||||||
|
pressure_turns = game["pressure_turns"]
|
||||||
|
avg_safe = game["avg_safe_options"]
|
||||||
|
|
||||||
|
pressure_ratio = pressure_turns / rows
|
||||||
|
safe_tightness = 0.0 if avg_safe is None else max(0.0, 3.0 - avg_safe)
|
||||||
|
return round(max_turn * 1.2 + pressure_ratio * 120.0 + safe_tightness * 20.0, 3)
|
||||||
|
|
||||||
|
def _extract_safe_options(self, row:dict):
|
||||||
|
top_level = row.get("safe_options")
|
||||||
|
if isinstance(top_level, int):
|
||||||
|
return top_level
|
||||||
|
|
||||||
|
history = row.get("history", {})
|
||||||
|
for item in history.get("data", []):
|
||||||
|
if item.get("function") != "get_possible_moves":
|
||||||
|
continue
|
||||||
|
safe_positions = item.get("safe_positions", {})
|
||||||
|
if isinstance(safe_positions, dict):
|
||||||
|
return len(safe_positions)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def analyze(self):
|
||||||
|
files = DatasetIO.resolve_input_files(self.input_files)
|
||||||
|
|
||||||
|
totals = {
|
||||||
|
"rows": 0,
|
||||||
|
"games": set(),
|
||||||
|
"snake_types": Counter(),
|
||||||
|
"game_types": Counter(),
|
||||||
|
"moves": Counter(),
|
||||||
|
"days": Counter(),
|
||||||
|
}
|
||||||
|
|
||||||
|
games = {}
|
||||||
|
day_games = defaultdict(set)
|
||||||
|
|
||||||
|
for file_path in files:
|
||||||
|
day = self._infer_day(file_path)
|
||||||
|
for row in DatasetIO.iter_jsonl_rows(file_path):
|
||||||
|
game_id = row.get("game_id")
|
||||||
|
if not game_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
turn = int(row.get("turn", 0))
|
||||||
|
safe_options = self._extract_safe_options(row)
|
||||||
|
snake_type = row.get("snake_type", "unknown")
|
||||||
|
move = row.get("move", "unknown")
|
||||||
|
|
||||||
|
game_type = row.get("game_type", {})
|
||||||
|
if isinstance(game_type, dict):
|
||||||
|
game_type_name = game_type.get("name", "unknown")
|
||||||
|
else:
|
||||||
|
game_type_name = str(game_type)
|
||||||
|
|
||||||
|
totals["rows"] += 1
|
||||||
|
totals["games"].add(game_id)
|
||||||
|
totals["snake_types"][snake_type] += 1
|
||||||
|
totals["game_types"][game_type_name] += 1
|
||||||
|
totals["moves"][move] += 1
|
||||||
|
totals["days"][day] += 1
|
||||||
|
|
||||||
|
if game_id not in games:
|
||||||
|
games[game_id] = {
|
||||||
|
"game_id": game_id,
|
||||||
|
"day": day,
|
||||||
|
"snake_type": snake_type,
|
||||||
|
"game_type": game_type_name,
|
||||||
|
"rows": 0,
|
||||||
|
"max_turn": -1,
|
||||||
|
"safe_options_sum": 0,
|
||||||
|
"safe_options_count": 0,
|
||||||
|
"pressure_turns": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
game = games[game_id]
|
||||||
|
game["rows"] += 1
|
||||||
|
game["max_turn"] = max(game["max_turn"], turn)
|
||||||
|
if isinstance(safe_options, int):
|
||||||
|
game["safe_options_sum"] += safe_options
|
||||||
|
game["safe_options_count"] += 1
|
||||||
|
if safe_options <= 2:
|
||||||
|
game["pressure_turns"] += 1
|
||||||
|
|
||||||
|
day_games[day].add(game_id)
|
||||||
|
|
||||||
|
game_summaries = []
|
||||||
|
for game in games.values():
|
||||||
|
avg_safe = None
|
||||||
|
if game["safe_options_count"] > 0:
|
||||||
|
avg_safe = round(
|
||||||
|
game["safe_options_sum"] / game["safe_options_count"], 3
|
||||||
|
)
|
||||||
|
item = {
|
||||||
|
"game_id": game["game_id"],
|
||||||
|
"day": game["day"],
|
||||||
|
"snake_type": game["snake_type"],
|
||||||
|
"game_type": game["game_type"],
|
||||||
|
"rows": game["rows"],
|
||||||
|
"max_turn": game["max_turn"],
|
||||||
|
"avg_safe_options": avg_safe,
|
||||||
|
"pressure_turns": game["pressure_turns"],
|
||||||
|
}
|
||||||
|
item["score"] = self._game_score(item)
|
||||||
|
item["pressure_score"] = self._pressure_score(item)
|
||||||
|
game_summaries.append(item)
|
||||||
|
|
||||||
|
game_summaries.sort(
|
||||||
|
key=lambda x: (x["score"], x["max_turn"], x["rows"]), reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
best_overall = game_summaries[0] if game_summaries else None
|
||||||
|
pressure_sorted = sorted(
|
||||||
|
game_summaries,
|
||||||
|
key=lambda x: (x["pressure_score"], x["max_turn"], x["rows"]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
best_pressure_overall = pressure_sorted[0] if pressure_sorted else None
|
||||||
|
|
||||||
|
by_day = {}
|
||||||
|
for day, game_ids in sorted(day_games.items()):
|
||||||
|
day_list = [item for item in game_summaries if item["game_id"] in game_ids]
|
||||||
|
day_list.sort(
|
||||||
|
key=lambda x: (x["score"], x["max_turn"], x["rows"]), reverse=True
|
||||||
|
)
|
||||||
|
day_pressure = sorted(
|
||||||
|
day_list,
|
||||||
|
key=lambda x: (x["pressure_score"], x["max_turn"], x["rows"]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_day[day] = {
|
||||||
|
"rows": totals["days"][day],
|
||||||
|
"games": len(game_ids),
|
||||||
|
"best_game": day_list[0] if day_list else None,
|
||||||
|
"best_pressure_game": day_pressure[0] if day_pressure else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"files_scanned": [str(path) for path in files],
|
||||||
|
"overall": {
|
||||||
|
"rows": totals["rows"],
|
||||||
|
"games": len(totals["games"]),
|
||||||
|
"snake_types": dict(totals["snake_types"]),
|
||||||
|
"game_types": dict(totals["game_types"]),
|
||||||
|
"moves": dict(totals["moves"]),
|
||||||
|
"best_game": best_overall,
|
||||||
|
"best_pressure_game": best_pressure_overall,
|
||||||
|
},
|
||||||
|
"by_day": by_day,
|
||||||
|
"top_games": game_summaries[:10],
|
||||||
|
"top_pressure_games": pressure_sorted[:10],
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Analyze Battlesnake JSONL datasets")
|
||||||
|
parser.add_argument(
|
||||||
|
"--input",
|
||||||
|
action="append",
|
||||||
|
required=True,
|
||||||
|
help="Input JSONL/JSONL.GZ file, directory, or glob pattern. Repeat for multiple inputs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=None,
|
||||||
|
help="Optional path to write JSON report",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
report = DatasetStats(args.input).analyze()
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
output_path = Path(args.output)
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
import os
|
||||||
|
|
||||||
|
from quart_common.web.env import env_bool, env_int
|
||||||
|
from server.dataset.DatasetIO import DatasetIO
|
||||||
|
|
||||||
|
class RLBootstrapDataset:
|
||||||
|
def __init__(self):
|
||||||
|
self.enabled = env_bool("RL_BOOTSTRAP_ENABLED", default=False)
|
||||||
|
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.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.needs_more_data = False
|
||||||
|
|
||||||
|
def refresh_state(self):
|
||||||
|
if not self.enabled:
|
||||||
|
self.needs_more_data = False
|
||||||
|
return
|
||||||
|
|
||||||
|
base_rows = DatasetIO.count_jsonl_rows(self.base_dataset_path)
|
||||||
|
self.needs_more_data = base_rows < self.min_base_rows
|
||||||
|
|
||||||
|
def record_sample(self, game_data:Any, move:str, safe_moves:dict[str, dict[str, int]], reason:str, scores:dict[str, float]|None=None):
|
||||||
|
if not self.enabled or not self.needs_more_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
row = {
|
||||||
|
"source": "best_battlesnake_bootstrap",
|
||||||
|
"game_id": getattr(game_data, "id", None),
|
||||||
|
"turn": game_data.get_turn(),
|
||||||
|
"move": move,
|
||||||
|
"safe_moves": list(safe_moves.keys()),
|
||||||
|
"reason": reason,
|
||||||
|
"game_board": game_data.get_game_board_as_dict(),
|
||||||
|
}
|
||||||
|
if scores:
|
||||||
|
row["scores"] = {k: round(v, 5) for k, v in scores.items()}
|
||||||
|
|
||||||
|
DatasetIO.append_jsonl_row(self.output_path, row)
|
||||||
|
DatasetIO.rotate_and_gzip_if_size_reached(self.output_path, self.max_bytes)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from .Dataset import Dataset
|
||||||
|
from .DatasetIO import DatasetIO
|
||||||
|
from .DatasetExporter import DatasetExporter
|
||||||
|
from .DatasetCurator import DatasetCurator
|
||||||
|
from .DatasetStats import DatasetStats
|
||||||
|
from .RLBootstrapDataset import RLBootstrapDataset
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
from server.metrics.backends.Template import StoreTemplate
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
class MetricsCollector:
|
||||||
|
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._stale_game_timeout_sec = stale_game_timeout_sec
|
||||||
|
self._game_last_seen_unix = game_last_seen_unix
|
||||||
|
self._game_move_counts = game_move_counts
|
||||||
|
self._metrics = {
|
||||||
|
'games_started': 0,
|
||||||
|
'games_ended': 0,
|
||||||
|
'wins': 0,
|
||||||
|
'losses': 0,
|
||||||
|
'total_moves': 0,
|
||||||
|
'total_turns': 0,
|
||||||
|
'max_turn': 0,
|
||||||
|
'active_games_peak': 0,
|
||||||
|
'games_autocreated': 0,
|
||||||
|
'http_requests_total': 0,
|
||||||
|
# 'http_requests_by_endpoint': {
|
||||||
|
# 'info': 0,
|
||||||
|
# 'start': 0,
|
||||||
|
# 'move': 0,
|
||||||
|
# 'end': 0,
|
||||||
|
# },
|
||||||
|
# 'move_direction_counts': {
|
||||||
|
# 'up': 0,
|
||||||
|
# 'down': 0,
|
||||||
|
# 'left': 0,
|
||||||
|
# 'right': 0,
|
||||||
|
# 'unknown': 0,
|
||||||
|
# },
|
||||||
|
'move_response_time_ms_total': 0.0,
|
||||||
|
'move_response_time_ms_max': 0.0,
|
||||||
|
'last_game_start_unix': 0,
|
||||||
|
'last_game_end_unix': 0,
|
||||||
|
'last_move_unix': 0,
|
||||||
|
'games_stuck_removed': 0,
|
||||||
|
'metrics_backend': metrics_backend,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── internal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _auto_publish(self) -> None:
|
||||||
|
snapshot = self.build_local_snapshot(self._game_last_seen_unix, self._game_move_counts)
|
||||||
|
await self._manager.publish_only(snapshot)
|
||||||
|
|
||||||
|
# ── record helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def record_http_request(self, endpoint:str) -> None:
|
||||||
|
self._metrics['http_requests_total'] += 1
|
||||||
|
# endpoint_counts = self._metrics['http_requests_by_endpoint']
|
||||||
|
# endpoint_counts[endpoint] = endpoint_counts.get(endpoint, 0) + 1
|
||||||
|
|
||||||
|
async def record_game_started(self, active_count:int) -> None:
|
||||||
|
self._metrics['games_started'] += 1
|
||||||
|
self._metrics['active_games_peak'] = max(
|
||||||
|
self._metrics['active_games_peak'],
|
||||||
|
active_count,
|
||||||
|
)
|
||||||
|
self._metrics['last_game_start_unix'] = int(time.time())
|
||||||
|
await self._auto_publish()
|
||||||
|
|
||||||
|
async def record_game_autocreated(self) -> None:
|
||||||
|
self._metrics['games_autocreated'] += 1
|
||||||
|
await self._auto_publish()
|
||||||
|
|
||||||
|
async def record_move(self, direction:str, elapsed_ms:float) -> None:
|
||||||
|
self._metrics['total_moves'] += 1
|
||||||
|
self._metrics['move_response_time_ms_total'] += elapsed_ms
|
||||||
|
self._metrics['move_response_time_ms_max'] = max(
|
||||||
|
self._metrics['move_response_time_ms_max'],
|
||||||
|
elapsed_ms,
|
||||||
|
)
|
||||||
|
# move_counts = self._metrics['move_direction_counts']
|
||||||
|
# if direction in move_counts:
|
||||||
|
# move_counts[direction] += 1
|
||||||
|
# else:
|
||||||
|
# move_counts['unknown'] += 1
|
||||||
|
# self._metrics['last_move_unix'] = int(time.time())
|
||||||
|
await self._auto_publish()
|
||||||
|
|
||||||
|
async def record_game_end(self, game_state:dict) -> None:
|
||||||
|
self._metrics['games_ended'] += 1
|
||||||
|
self._metrics['last_game_end_unix'] = int(time.time())
|
||||||
|
|
||||||
|
final_turn = int(game_state.get('turn', 0))
|
||||||
|
self._metrics['total_turns'] += final_turn
|
||||||
|
self._metrics['max_turn'] = max(self._metrics['max_turn'], final_turn)
|
||||||
|
|
||||||
|
you_id = game_state.get('you', {}).get('id')
|
||||||
|
alive_ids = {s.get('id') for s in game_state.get('board', {}).get('snakes', [])}
|
||||||
|
if you_id and you_id in alive_ids:
|
||||||
|
self._metrics['wins'] += 1
|
||||||
|
else:
|
||||||
|
self._metrics['losses'] += 1
|
||||||
|
await self._auto_publish()
|
||||||
|
|
||||||
|
async def record_stuck_removed(self) -> None:
|
||||||
|
self._metrics['games_stuck_removed'] += 1
|
||||||
|
await self._auto_publish()
|
||||||
|
|
||||||
|
# ── snapshot builders ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _calc_active_game_stats(self, game_last_seen_unix:dict) -> tuple[int, int, int]:
|
||||||
|
"""Returns (report_active_games, report_stale_candidates, oldest_active_age_sec)."""
|
||||||
|
now = int(time.time())
|
||||||
|
stale_candidates = sum(
|
||||||
|
1
|
||||||
|
for last_seen in game_last_seen_unix.values()
|
||||||
|
if now - last_seen >= self._stale_game_timeout_sec
|
||||||
|
)
|
||||||
|
|
||||||
|
report_active_games = len(game_last_seen_unix)
|
||||||
|
report_stale_candidates = stale_candidates
|
||||||
|
active_last_seen = list(game_last_seen_unix.values())
|
||||||
|
|
||||||
|
oldest_active_age = max(0, now - min(active_last_seen)) if active_last_seen else 0
|
||||||
|
return report_active_games, report_stale_candidates, oldest_active_age
|
||||||
|
|
||||||
|
def build_local_snapshot(self, game_last_seen_unix:dict, game_move_counts:dict) -> dict:
|
||||||
|
games_ended = self._metrics['games_ended']
|
||||||
|
total_moves = self._metrics['total_moves']
|
||||||
|
avg_turns = self._metrics['total_turns'] / games_ended if games_ended else 0.0
|
||||||
|
win_rate = self._metrics['wins'] / games_ended if games_ended else 0.0
|
||||||
|
avg_move_ms = (
|
||||||
|
self._metrics['move_response_time_ms_total'] / total_moves if total_moves else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
report_active_games, report_stale_candidates, oldest_active_age = self._calc_active_game_stats(game_last_seen_unix)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**self._metrics,
|
||||||
|
'active_games': report_active_games,
|
||||||
|
'tracked_games': len(game_move_counts),
|
||||||
|
'avg_turns_per_game': round(avg_turns, 2),
|
||||||
|
'win_rate': round(win_rate, 4),
|
||||||
|
'avg_move_response_ms': round(avg_move_ms, 2),
|
||||||
|
# 'http_requests_by_endpoint': dict(self._metrics['http_requests_by_endpoint']),
|
||||||
|
# 'move_direction_counts': dict(self._metrics['move_direction_counts']),
|
||||||
|
'oldest_active_game_age_sec': oldest_active_age,
|
||||||
|
'stale_game_timeout_sec': self._stale_game_timeout_sec,
|
||||||
|
'active_games_stale': report_stale_candidates,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def build_snapshot(self, game_last_seen_unix:dict, game_move_counts:dict) -> dict:
|
||||||
|
local_snapshot = self.build_local_snapshot(game_last_seen_unix, game_move_counts)
|
||||||
|
return await self._manager.snapshot(local_snapshot)
|
||||||
|
|
||||||
|
async def clear_worker_metrics(self) -> None:
|
||||||
|
await self._manager.clear_all_workers()
|
||||||
|
|
||||||
|
async def should_clear_worker_metrics_on_startup(self, lock_ttl_seconds:int=300) -> bool:
|
||||||
|
return await self._manager.acquire_startup_cleanup_lock(lock_ttl_seconds)
|
||||||
|
|
||||||
|
def build_prometheus_metrics(self, snapshot:dict) -> str:
|
||||||
|
lines = [
|
||||||
|
'# HELP snake_games_started_total Total games started by snake server.',
|
||||||
|
'# TYPE snake_games_started_total counter',
|
||||||
|
f'snake_games_started_total {snapshot["games_started"]}',
|
||||||
|
'# HELP snake_games_ended_total Total games ended by snake server.',
|
||||||
|
'# TYPE snake_games_ended_total counter',
|
||||||
|
f'snake_games_ended_total {snapshot["games_ended"]}',
|
||||||
|
'# HELP snake_wins_total Total games won by this snake.',
|
||||||
|
'# TYPE snake_wins_total counter',
|
||||||
|
f'snake_wins_total {snapshot["wins"]}',
|
||||||
|
'# HELP snake_losses_total Total games lost by this snake.',
|
||||||
|
'# TYPE snake_losses_total counter',
|
||||||
|
f'snake_losses_total {snapshot["losses"]}',
|
||||||
|
'# HELP snake_moves_total Total move decisions served by /move.',
|
||||||
|
'# TYPE snake_moves_total counter',
|
||||||
|
f'snake_moves_total {snapshot["total_moves"]}',
|
||||||
|
'# HELP snake_turns_total Total turns across all ended games.',
|
||||||
|
'# TYPE snake_turns_total counter',
|
||||||
|
f'snake_turns_total {snapshot["total_turns"]}',
|
||||||
|
'# HELP snake_active_games Currently active games in memory.',
|
||||||
|
'# TYPE snake_active_games gauge',
|
||||||
|
f'snake_active_games {snapshot["active_games"]}',
|
||||||
|
'# HELP snake_tracked_games Currently tracked game IDs for move counters.',
|
||||||
|
'# TYPE snake_tracked_games gauge',
|
||||||
|
f'snake_tracked_games {snapshot["tracked_games"]}',
|
||||||
|
'# HELP snake_max_turn Highest final turn seen in an ended game.',
|
||||||
|
'# TYPE snake_max_turn gauge',
|
||||||
|
f'snake_max_turn {snapshot["max_turn"]}',
|
||||||
|
'# HELP snake_active_games_peak Highest active game count observed.',
|
||||||
|
'# TYPE snake_active_games_peak gauge',
|
||||||
|
f'snake_active_games_peak {snapshot["active_games_peak"]}',
|
||||||
|
'# HELP snake_games_autocreated_total Games created on /move or /end due to missing /start.',
|
||||||
|
'# TYPE snake_games_autocreated_total counter',
|
||||||
|
f'snake_games_autocreated_total {snapshot["games_autocreated"]}',
|
||||||
|
'# HELP snake_http_requests_total Total HTTP requests handled by this process.',
|
||||||
|
'# TYPE snake_http_requests_total counter',
|
||||||
|
f'snake_http_requests_total {snapshot["http_requests_total"]}',
|
||||||
|
'# HELP snake_move_response_ms_total Total move endpoint compute time in milliseconds.',
|
||||||
|
'# TYPE snake_move_response_ms_total counter',
|
||||||
|
f'snake_move_response_ms_total {round(snapshot["move_response_time_ms_total"], 3)}',
|
||||||
|
'# HELP snake_move_response_ms_max Maximum move endpoint compute time in milliseconds.',
|
||||||
|
'# TYPE snake_move_response_ms_max gauge',
|
||||||
|
f'snake_move_response_ms_max {round(snapshot["move_response_time_ms_max"], 3)}',
|
||||||
|
'# HELP snake_avg_turns_per_game Average final turn per ended game.',
|
||||||
|
'# TYPE snake_avg_turns_per_game gauge',
|
||||||
|
f'snake_avg_turns_per_game {snapshot["avg_turns_per_game"]}',
|
||||||
|
'# HELP snake_avg_move_response_ms Average move endpoint compute time in milliseconds.',
|
||||||
|
'# TYPE snake_avg_move_response_ms gauge',
|
||||||
|
f'snake_avg_move_response_ms {snapshot["avg_move_response_ms"]}',
|
||||||
|
'# HELP snake_win_rate Win ratio from ended games (0.0 - 1.0).',
|
||||||
|
'# TYPE snake_win_rate gauge',
|
||||||
|
f'snake_win_rate {snapshot["win_rate"]}',
|
||||||
|
'# HELP snake_last_game_start_unix Unix timestamp of most recent /start request.',
|
||||||
|
'# TYPE snake_last_game_start_unix gauge',
|
||||||
|
f'snake_last_game_start_unix {snapshot["last_game_start_unix"]}',
|
||||||
|
'# HELP snake_last_game_end_unix Unix timestamp of most recent /end request.',
|
||||||
|
'# TYPE snake_last_game_end_unix gauge',
|
||||||
|
f'snake_last_game_end_unix {snapshot["last_game_end_unix"]}',
|
||||||
|
'# HELP snake_last_move_unix Unix timestamp of most recent /move response.',
|
||||||
|
'# TYPE snake_last_move_unix gauge',
|
||||||
|
f'snake_last_move_unix {snapshot["last_move_unix"]}',
|
||||||
|
'# HELP snake_games_stuck_removed_total Active games auto-removed due to inactivity timeout.',
|
||||||
|
'# TYPE snake_games_stuck_removed_total counter',
|
||||||
|
f'snake_games_stuck_removed_total {snapshot["games_stuck_removed"]}',
|
||||||
|
'# HELP snake_oldest_active_game_age_sec Age in seconds of the oldest active game.',
|
||||||
|
'# TYPE snake_oldest_active_game_age_sec gauge',
|
||||||
|
f'snake_oldest_active_game_age_sec {snapshot["oldest_active_game_age_sec"]}',
|
||||||
|
'# HELP snake_stale_game_timeout_sec Configured inactivity timeout for stale games.',
|
||||||
|
'# TYPE snake_stale_game_timeout_sec gauge',
|
||||||
|
f'snake_stale_game_timeout_sec {snapshot["stale_game_timeout_sec"]}',
|
||||||
|
'# HELP snake_active_games_stale Active games currently beyond stale timeout.',
|
||||||
|
'# TYPE snake_active_games_stale gauge',
|
||||||
|
f'snake_active_games_stale {snapshot["active_games_stale"]}',
|
||||||
|
]
|
||||||
|
|
||||||
|
# lines.extend([
|
||||||
|
# '# HELP snake_http_requests_by_endpoint_total Requests served grouped by endpoint.',
|
||||||
|
# '# TYPE snake_http_requests_by_endpoint_total counter',
|
||||||
|
# ])
|
||||||
|
# for endpoint, count in snapshot['http_requests_by_endpoint'].items():
|
||||||
|
# lines.append(f'snake_http_requests_by_endpoint_total{{endpoint="{endpoint}"}} {count}')
|
||||||
|
|
||||||
|
# lines.extend([
|
||||||
|
# '# HELP snake_moves_by_direction_total Move responses grouped by direction.',
|
||||||
|
# '# TYPE snake_moves_by_direction_total counter',
|
||||||
|
# ])
|
||||||
|
# for direction, count in snapshot['move_direction_counts'].items():
|
||||||
|
# lines.append(f'snake_moves_by_direction_total{{direction="{direction}"}} {count}')
|
||||||
|
|
||||||
|
return '\n'.join(lines) + '\n'
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
await self._manager.close()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from .backends import StoreTemplate, MemoryMetricsStore, RedisMetricsStore
|
||||||
|
|
||||||
|
from .MetricsCollector import MetricsCollector
|
||||||
|
|
||||||
|
class MetricsStoreBuilder:
|
||||||
|
@classmethod
|
||||||
|
def build(self, backend:str="memory", **kwargs) -> StoreTemplate:
|
||||||
|
selected = (backend or "memory").strip().lower()
|
||||||
|
if selected == "redis":
|
||||||
|
return RedisMetricsStore(**kwargs)
|
||||||
|
return MemoryMetricsStore(**kwargs)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from server.metrics.backends.Template import StoreTemplate
|
||||||
|
|
||||||
|
class MemoryMetricsStore(StoreTemplate):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(backend="memory", **kwargs)
|
||||||
|
self._snapshots:dict[str, dict] = {}
|
||||||
|
|
||||||
|
async def publish(self, worker_id:str, snapshot:dict) -> None:
|
||||||
|
self._snapshots[worker_id] = dict(snapshot)
|
||||||
|
|
||||||
|
async def load_all(self) -> list[dict]:
|
||||||
|
return [dict(value) for value in self._snapshots.values()]
|
||||||
|
|
||||||
|
async def clear_all(self) -> None:
|
||||||
|
self._snapshots.clear()
|
||||||
|
|
||||||
|
async def _acquire_startup_cleanup_lock(self, lock_key:str, ttl_seconds:int=300) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from server.metrics.backends.Template import StoreTemplate
|
||||||
|
|
||||||
|
import inspect, json
|
||||||
|
|
||||||
|
class RedisMetricsStore(StoreTemplate):
|
||||||
|
def __init__(self, redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:metrics:worker", ttl_seconds:int|None=None, **kwargs):
|
||||||
|
super().__init__(backend="redis", key_prefix=key_prefix, **kwargs)
|
||||||
|
self.redis_url = redis_url
|
||||||
|
self.key_prefix = key_prefix
|
||||||
|
self.ttl_seconds = 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("Metrics backend set to redis but 'redis' package is not installed") from error
|
||||||
|
|
||||||
|
self._redis = aioredis.from_url(self.redis_url)
|
||||||
|
return self._redis
|
||||||
|
|
||||||
|
def _key(self, worker_id:str) -> str:
|
||||||
|
return f"{self.key_prefix}:{worker_id}"
|
||||||
|
|
||||||
|
async def publish(self, worker_id:str, snapshot:dict) -> None:
|
||||||
|
redis = await self._get_redis()
|
||||||
|
await redis.set(self._key(worker_id), json.dumps(snapshot), ex=self.ttl_seconds)
|
||||||
|
|
||||||
|
async def load_all(self) -> list[dict]:
|
||||||
|
redis = await self._get_redis()
|
||||||
|
keys = await redis.keys(f"{self.key_prefix}:*")
|
||||||
|
snapshots = []
|
||||||
|
for key in keys:
|
||||||
|
payload = await redis.get(key)
|
||||||
|
if not payload:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
snapshots.append(json.loads(payload))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
async def clear_all(self) -> None:
|
||||||
|
redis = await self._get_redis()
|
||||||
|
keys = await redis.keys(f"{self.key_prefix}:*")
|
||||||
|
if keys:
|
||||||
|
await redis.delete(*keys)
|
||||||
|
|
||||||
|
async def _acquire_startup_cleanup_lock(self, lock_key:str, ttl_seconds:int=300) -> bool:
|
||||||
|
redis = await self._get_redis()
|
||||||
|
locked = await redis.set(lock_key, '1', ex=max(1, int(ttl_seconds)), nx=True)
|
||||||
|
return bool(locked)
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from typing import Any, Awaitable, cast
|
||||||
|
import inspect, time, os
|
||||||
|
|
||||||
|
class StoreTemplate:
|
||||||
|
def __init__(self, backend:str="memory", key_prefix:str="snake:metrics:worker", worker_id:str|None=None, **kwargs):
|
||||||
|
self.backend = (backend or "memory").strip().lower()
|
||||||
|
self.key_prefix = key_prefix
|
||||||
|
self.worker_id = worker_id or f"{os.getpid()}-{int(time.time() * 1000)}"
|
||||||
|
self.store = self
|
||||||
|
|
||||||
|
async def publish(self, worker_id:str, snapshot:dict) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def load_all(self) -> list[dict]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def publish_only(self, snapshot:dict) -> None:
|
||||||
|
await self.store.publish(self.worker_id, snapshot)
|
||||||
|
|
||||||
|
async def snapshot(self, local_snapshot:dict) -> dict:
|
||||||
|
await self.store.publish(self.worker_id, local_snapshot)
|
||||||
|
|
||||||
|
if self.backend != "redis":
|
||||||
|
return local_snapshot
|
||||||
|
|
||||||
|
snapshots = await self.store.load_all()
|
||||||
|
if not snapshots:
|
||||||
|
return local_snapshot
|
||||||
|
return self._merge_snapshots(snapshots)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self.store is self:
|
||||||
|
return
|
||||||
|
close_store = getattr(self.store, "close", None)
|
||||||
|
if not callable(close_store):
|
||||||
|
return
|
||||||
|
maybe_result = close_store()
|
||||||
|
if inspect.isawaitable(maybe_result):
|
||||||
|
await cast(Awaitable[Any], maybe_result)
|
||||||
|
|
||||||
|
async def clear_all_workers(self) -> None:
|
||||||
|
clear_all = getattr(self.store, "clear_all", None)
|
||||||
|
if callable(clear_all):
|
||||||
|
maybe_result = clear_all()
|
||||||
|
if inspect.isawaitable(maybe_result):
|
||||||
|
await cast(Awaitable[Any], maybe_result)
|
||||||
|
|
||||||
|
async def acquire_startup_cleanup_lock(self, ttl_seconds:int=300) -> bool:
|
||||||
|
if self.backend != "redis":
|
||||||
|
return True
|
||||||
|
|
||||||
|
acquire_lock = getattr(self.store, "_acquire_startup_cleanup_lock", None)
|
||||||
|
if not callable(acquire_lock):
|
||||||
|
acquire_lock = getattr(self.store, "acquire_startup_cleanup_lock", None)
|
||||||
|
if not callable(acquire_lock):
|
||||||
|
return True
|
||||||
|
|
||||||
|
lock_key = f"{self.key_prefix}:startup_cleanup_lock"
|
||||||
|
maybe_result = acquire_lock(lock_key, ttl_seconds)
|
||||||
|
if inspect.isawaitable(maybe_result):
|
||||||
|
return bool(await cast(Awaitable[Any], maybe_result))
|
||||||
|
return bool(maybe_result)
|
||||||
|
|
||||||
|
def _merge_snapshots(self, snapshots:list[dict]) -> dict:
|
||||||
|
merged = {
|
||||||
|
"games_started": 0,
|
||||||
|
"games_ended": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"total_moves": 0,
|
||||||
|
"total_turns": 0,
|
||||||
|
"max_turn": 0,
|
||||||
|
"active_games_peak": 0,
|
||||||
|
"games_autocreated": 0,
|
||||||
|
"http_requests_total": 0,
|
||||||
|
"move_response_time_ms_total": 0.0,
|
||||||
|
"move_response_time_ms_max": 0.0,
|
||||||
|
"last_game_start_unix": 0,
|
||||||
|
"last_game_end_unix": 0,
|
||||||
|
"last_move_unix": 0,
|
||||||
|
"games_stuck_removed": 0,
|
||||||
|
"metrics_backend": "redis",
|
||||||
|
"active_games": 0,
|
||||||
|
"tracked_games": 0,
|
||||||
|
"oldest_active_game_age_sec": 0,
|
||||||
|
"stale_game_timeout_sec": 0,
|
||||||
|
"active_games_stale": 0,
|
||||||
|
"http_requests_by_endpoint": {"info": 0, "start": 0, "move": 0, "end": 0},
|
||||||
|
"move_direction_counts": {
|
||||||
|
"up": 0,
|
||||||
|
"down": 0,
|
||||||
|
"left": 0,
|
||||||
|
"right": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for worker in snapshots:
|
||||||
|
for metric_name in (
|
||||||
|
"games_started",
|
||||||
|
"games_ended",
|
||||||
|
"wins",
|
||||||
|
"losses",
|
||||||
|
"total_moves",
|
||||||
|
"total_turns",
|
||||||
|
"games_autocreated",
|
||||||
|
"http_requests_total",
|
||||||
|
"games_stuck_removed",
|
||||||
|
"active_games",
|
||||||
|
"tracked_games",
|
||||||
|
"active_games_stale",
|
||||||
|
):
|
||||||
|
merged[metric_name] += int(worker.get(metric_name, 0))
|
||||||
|
|
||||||
|
merged["move_response_time_ms_total"] += float(worker.get("move_response_time_ms_total", 0.0))
|
||||||
|
merged["max_turn"] = max(merged["max_turn"], int(worker.get("max_turn", 0)))
|
||||||
|
merged["active_games_peak"] = max(merged["active_games_peak"], int(worker.get("active_games_peak", 0)))
|
||||||
|
merged["move_response_time_ms_max"] = max(merged["move_response_time_ms_max"], float(worker.get("move_response_time_ms_max", 0.0)))
|
||||||
|
merged["last_game_start_unix"] = max(merged["last_game_start_unix"], int(worker.get("last_game_start_unix", 0)))
|
||||||
|
merged["last_game_end_unix"] = max(merged["last_game_end_unix"], int(worker.get("last_game_end_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["stale_game_timeout_sec"] = max(merged["stale_game_timeout_sec"], int(worker.get("stale_game_timeout_sec", 0)))
|
||||||
|
|
||||||
|
for endpoint in merged["http_requests_by_endpoint"]:
|
||||||
|
merged["http_requests_by_endpoint"][endpoint] += int(worker.get("http_requests_by_endpoint", {}).get(endpoint, 0))
|
||||||
|
for direction in merged["move_direction_counts"]:
|
||||||
|
merged["move_direction_counts"][direction] += int(worker.get("move_direction_counts", {}).get(direction, 0))
|
||||||
|
|
||||||
|
games_ended = merged["games_ended"]
|
||||||
|
total_moves = merged["total_moves"]
|
||||||
|
merged["avg_turns_per_game"] = round((merged["total_turns"] / games_ended) if games_ended else 0.0, 2)
|
||||||
|
merged["win_rate"] = round((merged["wins"] / games_ended) if games_ended else 0.0, 4)
|
||||||
|
merged["avg_move_response_ms"] = round((merged["move_response_time_ms_total"] / total_moves) if total_moves else 0.0, 2)
|
||||||
|
return merged
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .Template import StoreTemplate
|
||||||
|
from .Memory import MemoryMetricsStore
|
||||||
|
from .Redis import RedisMetricsStore
|
||||||
@@ -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,59 +0,0 @@
|
|||||||
from server.GameBoard import GameBoard
|
|
||||||
from server.Files import save_file
|
|
||||||
|
|
||||||
import json, os
|
|
||||||
|
|
||||||
class LocalStorage:
|
|
||||||
def __init__(self, file_path:str, **kwargs):
|
|
||||||
self.save_folder_dict = {
|
|
||||||
"standard": "01_Standard",
|
|
||||||
"duel": "02_Duels",
|
|
||||||
"constrictor": "04_Constrictor",
|
|
||||||
"solo": "05_Solo",
|
|
||||||
}
|
|
||||||
self.file_path = file_path
|
|
||||||
|
|
||||||
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
|
|
||||||
if leader_board:
|
|
||||||
storage_folder = os.path.join(storage_folder, "00_Leaderboards")
|
|
||||||
|
|
||||||
storage_folder = os.path.join(storage_folder, self.save_folder_dict[game_type])
|
|
||||||
storage_folder = os.path.join(storage_folder, game_board.now_date.strftime('%Y'), game_board.now_date.strftime('%m_%B'), game_board.now_date.strftime('%d'))
|
|
||||||
|
|
||||||
if winner:
|
|
||||||
storage_folder = os.path.join(storage_folder, "Winner")
|
|
||||||
else:
|
|
||||||
storage_folder = os.path.join(storage_folder, "Lost")
|
|
||||||
|
|
||||||
return os.path.join(storage_folder, file_name)
|
|
||||||
|
|
||||||
async def save(self, game_board:GameBoard):
|
|
||||||
game_type = game_board.get_type_of_game()
|
|
||||||
save_file_path = self._get_correct_folder_for_save_file(
|
|
||||||
game_board,
|
|
||||||
f"{game_board.snake_class.__class__.__name__}_{game_board.now_date.strftime('%H-%M-%S')}_{game_board.id}.json",
|
|
||||||
game_type["name"],
|
|
||||||
game_type["is_ladder"],
|
|
||||||
True if game_board.winner_snake_names and "me" in game_board.winner_snake_names else False
|
|
||||||
)
|
|
||||||
|
|
||||||
await save_file(save_file_path, {
|
|
||||||
"winner": game_board.winner_snake_names,
|
|
||||||
"game": {
|
|
||||||
"url": game_board.url,
|
|
||||||
"id": game_board.id,
|
|
||||||
"final_turns": game_board.turn,
|
|
||||||
"map": game_board.map,
|
|
||||||
"type": game_type,
|
|
||||||
"ruleset": game_board.ruleset,
|
|
||||||
},
|
|
||||||
"moves": game_board.turns,
|
|
||||||
"snake": {
|
|
||||||
"type": game_board.snake_class.__class__.__name__,
|
|
||||||
"calculations": game_board.snake_class.get_history(),
|
|
||||||
},
|
|
||||||
}, callback=json.dump, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
pass
|
|
||||||
@@ -1,7 +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
|
|
||||||
@@ -3,219 +3,222 @@ from server.GameBoard import GameBoard
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
class BetterMasterSnake(TemplateSnake):
|
class BetterMasterSnake(TemplateSnake):
|
||||||
def __init__(self):
|
VERSION = "1.3.0"
|
||||||
super().__init__()
|
|
||||||
self.name = "BetterMasterSnake"
|
|
||||||
# Definiere die möglichen Bewegungsrichtungen
|
|
||||||
self.min_safe_area = 2
|
|
||||||
|
|
||||||
def choose_move(self, game_data:GameBoard):
|
def __init__(self):
|
||||||
self.game_board = game_data
|
super().__init__()
|
||||||
self.calculations = []
|
self.name = "BetterMasterSnake"
|
||||||
self.eat_the_snake_overwrite = False
|
self.version = self.VERSION
|
||||||
|
# Definiere die möglichen Bewegungsrichtungen
|
||||||
|
self.min_safe_area = 2
|
||||||
|
|
||||||
self.safe_positions = self.find_safe_positions(add_to_calculations=True)
|
def choose_move(self, game_data:GameBoard):
|
||||||
if self.eat_the_snake_overwrite:
|
self.game_board = game_data
|
||||||
return self.overwrite_eat_the_other_snake(game_data.get_turn())
|
self.calculations = []
|
||||||
|
self.eat_the_snake_overwrite = False
|
||||||
|
|
||||||
if game_data.get_type() == "constrictor":
|
self.safe_positions = self.find_safe_positions(add_to_calculations=True)
|
||||||
move = self.selected_move_constrictor()
|
if self.eat_the_snake_overwrite:
|
||||||
|
return self.overwrite_eat_the_other_snake(game_data.get_turn())
|
||||||
|
|
||||||
|
if game_data.get_type() == "constrictor":
|
||||||
|
move = self.selected_move_constrictor()
|
||||||
|
else:
|
||||||
|
move = self.selected_move_standard()
|
||||||
|
|
||||||
|
self.add_to_history({"turn": game_data.get_turn(), "data": self.calculations})
|
||||||
|
return move if move else "up"
|
||||||
|
|
||||||
|
def overwrite_eat_the_other_snake(self, turn:int):
|
||||||
|
self.add_calculations({"function": "eat_the_snake_overwrite", "my_head": self.game_board.get_my_snake_head(), "move": self.kill_the_snake, "safe_positions": self.safe_positions})
|
||||||
|
self.add_to_history({"turn": turn, "data": self.calculations})
|
||||||
|
return self.kill_the_snake
|
||||||
|
|
||||||
|
#TODO: How to Fill the Gameboard best?
|
||||||
|
def selected_move_constrictor(self):
|
||||||
|
move = self.move_close_to_body()
|
||||||
|
self.add_calculations({"function": "move_close_to_body", "my_head": self.game_board.get_my_snake_head(), "move": move})
|
||||||
|
move = self.ensure_escape_route(move)
|
||||||
|
self.add_calculations({"function": "ensure_escape_route", "my_head": self.game_board.get_my_snake_head(), "move": move, "safe_positions": self.safe_positions})
|
||||||
|
return move
|
||||||
|
|
||||||
|
def selected_move_standard(self, move=None):
|
||||||
|
# Finde den besten Weg zur Nahrung
|
||||||
|
path_to_food = self.find_path_to_food()
|
||||||
|
if path_to_food:
|
||||||
|
move = self.move_towards(path_to_food[0])
|
||||||
|
self.add_calculations({"function": "move_towards", "my_head": self.game_board.get_my_snake_head(), "path_to_food": path_to_food, "move": move})
|
||||||
|
|
||||||
|
if not move or self.would_eating_the_food_kill_the_snake(move):
|
||||||
|
move = self.move_close_to_body(move_close_to_tail=True)
|
||||||
|
self.add_calculations({"function": "move_close_to_body", "my_head": self.game_board.get_my_snake_head(), "move": move})
|
||||||
|
|
||||||
|
# Überprfe, ob der Zug einen Ausweg lässt
|
||||||
|
move = self.ensure_escape_route(move)
|
||||||
|
self.add_calculations({"function": "ensure_escape_route", "my_head": self.game_board.get_my_snake_head(), "move": move, "safe_positions": self.safe_positions})
|
||||||
|
return move
|
||||||
|
|
||||||
|
def find_path_to_food(self):
|
||||||
|
# Exclude own snake's body from obstacles
|
||||||
|
obstacles = set((part['x'], part['y']) for part in self.game_board.get_my_snake_body())
|
||||||
|
|
||||||
|
for snake in self.game_board.get_other_snakes():
|
||||||
|
for part in snake['body']:
|
||||||
|
obstacles.add((part['x'], part['y']))
|
||||||
|
|
||||||
|
other_snakes_other_snake_posible_moves_set = {(d['x'], d['y']) for d in self.other_snake_posible_moves}
|
||||||
|
removed_elements_set = set([(elem['x'], elem['y']) for elem in self.game_board.get_food() if (elem['x'], elem['y']) in other_snakes_other_snake_posible_moves_set])
|
||||||
|
obstacles |= removed_elements_set
|
||||||
|
|
||||||
|
self.food_positions = [elem for elem in self.game_board.get_food() if (elem['x'], elem['y']) not in other_snakes_other_snake_posible_moves_set]
|
||||||
|
|
||||||
|
if len(self.food_positions) > 0:
|
||||||
|
# Choose the closest food source based on the heuristic
|
||||||
|
closest_food = min(self.food_positions, key=lambda food: abs(food['x'] - self.game_board.get_my_snake_head()['x']) + abs(food['y'] - self.game_board.get_my_snake_head()['y']))
|
||||||
|
self.set_target_food(closest_food)
|
||||||
|
|
||||||
|
# Use A* to search for a safe path
|
||||||
|
return self.a_star_search(self.game_board.get_my_snake_head(), closest_food, obstacles)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_path_to_tail(self):
|
||||||
|
# Exclude other snake's body from obstacles
|
||||||
|
obstacles = set((part['x'], part['y']) for part in self.game_board.get_my_snake_body())
|
||||||
|
for snake in self.game_board.get_other_snakes():
|
||||||
|
for part in snake['body']:
|
||||||
|
obstacles.add((part['x'], part['y']))
|
||||||
|
|
||||||
|
my_snake_tail = {"x": self.game_board.get_my_snake_tail()['x'], "y": self.game_board.get_my_snake_tail()['y']}
|
||||||
|
|
||||||
|
# Use A* to search for a safe path
|
||||||
|
path = self.a_star_search(self.game_board.get_my_snake_head(), my_snake_tail, obstacles)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def move_towards(self, target):
|
||||||
|
best_direction = None
|
||||||
|
min_distance = float('inf')
|
||||||
|
for direction, coords in self.safe_positions.items():
|
||||||
|
distance = abs(target['x'] - coords['x']) + abs(target['y'] - coords['y'])
|
||||||
|
if distance < min_distance:
|
||||||
|
min_distance = distance
|
||||||
|
best_direction = direction
|
||||||
|
|
||||||
|
return best_direction if best_direction else "up"
|
||||||
|
|
||||||
|
def move_close_to_body(self, move_close_to_tail=False):
|
||||||
|
# Heuristik, um Positionen nahe dem eigenen Körper zu bevorzugen
|
||||||
|
body_positions = set((part['x'], part['y']) for part in self.game_board.get_my_snake_body())
|
||||||
|
tail_position = (self.game_board.get_my_snake_tail()['x'], self.game_board.get_my_snake_tail()['y'])
|
||||||
|
|
||||||
|
best_move = None
|
||||||
|
max_distance = -1 # Initialize maximum distance
|
||||||
|
for direction, pos in self.safe_positions.items():
|
||||||
|
next_position = (pos['x'], pos['y'])
|
||||||
|
if next_position in self.safe_positions:
|
||||||
|
# Berechne die Distanz zum eigenen Körper
|
||||||
|
distance_to_body = min(abs(next_position[0] - part[0]) + abs(next_position[1] - part[1]) for part in body_positions)
|
||||||
|
# Berechne die Distanz zum eigenen Schwanz
|
||||||
|
distance_to_tail = abs(next_position[0] - tail_position[0]) + abs(next_position[1] - tail_position[1])
|
||||||
|
# Wähle die maximale Distanz (Körper oder Schwanz)
|
||||||
|
if move_close_to_tail:
|
||||||
|
distance = min(next_position, distance_to_tail)
|
||||||
else:
|
else:
|
||||||
move = self.selected_move_standard()
|
distance = max(next_position, distance_to_body)
|
||||||
|
# Update max_distance if a larger distance is found
|
||||||
|
if distance > max_distance:
|
||||||
|
max_distance = distance
|
||||||
|
best_move = direction
|
||||||
|
return best_move if best_move else "up" # Standardbewegung, falls keine bessere gefunden wird
|
||||||
|
|
||||||
self.add_to_history({"turn": game_data.get_turn(), "data": self.calculations})
|
#TODO: Neat to Implement Function to check if eating the food would kill the snake?
|
||||||
return move if move else "up"
|
def would_eating_the_food_kill_the_snake(self, move:str):
|
||||||
|
return False
|
||||||
|
|
||||||
def overwrite_eat_the_other_snake(self, turn:int):
|
def ensure_escape_route(self, move:str):
|
||||||
self.add_calculations({"function": "eat_the_snake_overwrite", "my_head": self.game_board.get_my_snake_head(), "move": self.kill_the_snake, "safe_positions": self.safe_positions})
|
try:
|
||||||
self.add_to_history({"turn": turn, "data": self.calculations})
|
future_position = self.safe_positions[move]
|
||||||
return self.kill_the_snake
|
except KeyError:
|
||||||
|
for move, pos in self.safe_positions.items():
|
||||||
|
if self.is_near_tail(pos, (self.game_board.get_my_snake_tail()['x'], self.game_board.get_my_snake_tail()['y'])):
|
||||||
|
self.add_calculations({"function": "ensure_escape_route", "move": move, "is_near_tail": True})
|
||||||
|
move = self.move_towards(pos)
|
||||||
|
return move
|
||||||
|
else:
|
||||||
|
path_to_tail = self.find_path_to_tail()
|
||||||
|
if path_to_tail:
|
||||||
|
self.add_calculations({"function": "move_towards", "my_head": self.game_board.get_my_snake_head(), "path_to_tail": path_to_tail, "move": move})
|
||||||
|
move = self.move_towards(path_to_tail[0])
|
||||||
|
|
||||||
#TODO: How to Fill the Gameboard best?
|
self.add_calculations({"function": "ensure_escape_route", "move": move, "KeyError": "Snake Coild itself up"})
|
||||||
def selected_move_constrictor(self):
|
#return move
|
||||||
move = self.move_close_to_body()
|
|
||||||
self.add_calculations({"function": "move_close_to_body", "my_head": self.game_board.get_my_snake_head(), "move": move})
|
|
||||||
move = self.ensure_escape_route(move)
|
|
||||||
self.add_calculations({"function": "ensure_escape_route", "my_head": self.game_board.get_my_snake_head(), "move": move, "safe_positions": self.safe_positions})
|
|
||||||
return move
|
|
||||||
|
|
||||||
def selected_move_standard(self, move=None):
|
# TODO: Fix - Snake Neat to find the best way - Close to the Tail and maybe fill most free cells as posible
|
||||||
# Finde den besten Weg zur Nahrung
|
return move
|
||||||
path_to_food = self.find_path_to_food()
|
|
||||||
if path_to_food:
|
|
||||||
move = self.move_towards(path_to_food[0])
|
|
||||||
self.add_calculations({"function": "move_towards", "my_head": self.game_board.get_my_snake_head(), "path_to_food": path_to_food, "move": move})
|
|
||||||
|
|
||||||
if not move or self.would_eating_the_food_kill_the_snake(move):
|
def is_near_tail(self, position, tail):
|
||||||
move = self.move_close_to_body(move_close_to_tail=True)
|
return abs(position["x"] - tail[0]) + abs(position["y"] - tail[1]) <= 2
|
||||||
self.add_calculations({"function": "move_close_to_body", "my_head": self.game_board.get_my_snake_head(), "move": move})
|
|
||||||
|
|
||||||
# Überprfe, ob der Zug einen Ausweg lässt
|
def a_star_search(self, start, goal, obstacles):
|
||||||
move = self.ensure_escape_route(move)
|
# Helper functions
|
||||||
self.add_calculations({"function": "ensure_escape_route", "my_head": self.game_board.get_my_snake_head(), "move": move, "safe_positions": self.safe_positions})
|
def is_position_safe(position):
|
||||||
return move
|
return 0 <= position['x'] < self.game_board.get_width() and 0 <= position['y'] < self.game_board.get_height() and (position['x'], position['y']) not in obstacles
|
||||||
|
|
||||||
def find_path_to_food(self):
|
def get_neighbors(position):
|
||||||
# Exclude own snake's body from obstacles
|
neighbors = []
|
||||||
obstacles = set((part['x'], part['y']) for part in self.game_board.get_my_snake_body())
|
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: # links, rechts, oben, unten
|
||||||
|
neighbor = {'x': position['x'] + dx, 'y': position['y'] + dy}
|
||||||
|
if is_position_safe(neighbor):
|
||||||
|
neighbors.append(neighbor)
|
||||||
|
return neighbors
|
||||||
|
|
||||||
for snake in self.game_board.get_other_snakes():
|
def heuristic(position, goal):
|
||||||
for part in snake['body']:
|
# Verwenden Sie eine Heuristik, die immer positiv ist, selbst wenn das Ziel in der Nähe ist
|
||||||
obstacles.add((part['x'], part['y']))
|
return max(abs(position['x'] - goal['x']), abs(position['y'] - goal['y']))
|
||||||
|
|
||||||
other_snakes_other_snake_posible_moves_set = {(d['x'], d['y']) for d in self.other_snake_posible_moves}
|
# Überprüfen, ob das Ziel direkt neben dem Startpunkt liegt
|
||||||
removed_elements_set = set([(elem['x'], elem['y']) for elem in self.game_board.get_food() if (elem['x'], elem['y']) in other_snakes_other_snake_posible_moves_set])
|
if start == goal or (abs(start['x'] - goal['x']) <= 1 and abs(start['y'] - goal['y']) <= 1):
|
||||||
obstacles |= removed_elements_set
|
# Wenn das Ziel neben dem Startpunkt liegt, ist der Pfad das Ziel selbst
|
||||||
|
return [goal]
|
||||||
|
|
||||||
self.food_positions = [elem for elem in self.game_board.get_food() if (elem['x'], elem['y']) not in other_snakes_other_snake_posible_moves_set]
|
# Initialize the open and closed list
|
||||||
|
open_set = set([(start['x'], start['y'])])
|
||||||
|
came_from = {}
|
||||||
|
g_score = {(start['x'], start['y']): 0}
|
||||||
|
f_score = {(start['x'], start['y']): heuristic(start, goal)}
|
||||||
|
|
||||||
if len(self.food_positions) > 0:
|
while open_set:
|
||||||
# Choose the closest food source based on the heuristic
|
current = min(open_set, key=lambda pos: f_score.get(pos, float('inf')))
|
||||||
closest_food = min(self.food_positions, key=lambda food: abs(food['x'] - self.game_board.get_my_snake_head()['x']) + abs(food['y'] - self.game_board.get_my_snake_head()['y']))
|
current_dict = {'x': current[0], 'y': current[1]}
|
||||||
self.set_target_food(closest_food)
|
if current_dict == goal:
|
||||||
|
# Reconstruct the path
|
||||||
|
path = []
|
||||||
|
while current in came_from:
|
||||||
|
current = came_from[current]
|
||||||
|
path.append({'x': current[0], 'y': current[1]})
|
||||||
|
path.reverse()
|
||||||
|
if path and path[0] == start:
|
||||||
|
path.pop(0) # Entferne das erste Element, wenn es dem Start entspricht
|
||||||
|
return path # Return the path as a list of dicts
|
||||||
|
|
||||||
# Use A* to search for a safe path
|
open_set.remove(current)
|
||||||
return self.a_star_search(self.game_board.get_my_snake_head(), closest_food, obstacles)
|
for neighbor in get_neighbors(current_dict):
|
||||||
return None
|
neighbor_tuple = (neighbor['x'], neighbor['y'])
|
||||||
|
tentative_g_score = g_score[current] + 1 # Distance between neighbors is always 1
|
||||||
|
if tentative_g_score < g_score.get(neighbor_tuple, float('inf')):
|
||||||
|
came_from[neighbor_tuple] = current
|
||||||
|
g_score[neighbor_tuple] = tentative_g_score
|
||||||
|
f_score[neighbor_tuple] = g_score[neighbor_tuple] + heuristic(neighbor, goal)
|
||||||
|
if neighbor_tuple not in open_set:
|
||||||
|
open_set.add(neighbor_tuple)
|
||||||
|
|
||||||
def find_path_to_tail(self):
|
return None # Kein Pfad gefunden
|
||||||
# Exclude other snake's body from obstacles
|
|
||||||
obstacles = set((part['x'], part['y']) for part in self.game_board.get_my_snake_body())
|
|
||||||
for snake in self.game_board.get_other_snakes():
|
|
||||||
for part in snake['body']:
|
|
||||||
obstacles.add((part['x'], part['y']))
|
|
||||||
|
|
||||||
my_snake_tail = {"x": self.game_board.get_my_snake_tail()['x'], "y": self.game_board.get_my_snake_tail()['y']}
|
def find_direction(self):
|
||||||
|
# Beispielhafte Logik zur Auswahl einer Bewegungsrichtung
|
||||||
# Use A* to search for a safe path
|
for direction, pos in self.safe_positions.items():
|
||||||
path = self.a_star_search(self.game_board.get_my_snake_head(), my_snake_tail, obstacles)
|
next_position = (pos['x'], pos['y'])
|
||||||
return path
|
# Konvertiere safe_positions in eine Liste von Tupeln für den Vergleich
|
||||||
|
safe_positions_tuples = [(pos['x'], pos['y']) for pos in self.safe_positions.values()]
|
||||||
def move_towards(self, target):
|
if next_position in safe_positions_tuples:
|
||||||
best_direction = None
|
return direction
|
||||||
min_distance = float('inf')
|
return "up" # Standardbewegung, falls keine sichere Position gefunden wird
|
||||||
for direction, coords in self.safe_positions.items():
|
|
||||||
distance = abs(target['x'] - coords['x']) + abs(target['y'] - coords['y'])
|
|
||||||
if distance < min_distance:
|
|
||||||
min_distance = distance
|
|
||||||
best_direction = direction
|
|
||||||
|
|
||||||
return best_direction if best_direction else "up"
|
|
||||||
|
|
||||||
def move_close_to_body(self, move_close_to_tail=False):
|
|
||||||
# Heuristik, um Positionen nahe dem eigenen Körper zu bevorzugen
|
|
||||||
body_positions = set((part['x'], part['y']) for part in self.game_board.get_my_snake_body())
|
|
||||||
tail_position = (self.game_board.get_my_snake_tail()['x'], self.game_board.get_my_snake_tail()['y'])
|
|
||||||
|
|
||||||
best_move = None
|
|
||||||
max_distance = -1 # Initialize maximum distance
|
|
||||||
for direction, pos in self.safe_positions.items():
|
|
||||||
next_position = (pos['x'], pos['y'])
|
|
||||||
if next_position in self.safe_positions:
|
|
||||||
# Berechne die Distanz zum eigenen Körper
|
|
||||||
distance_to_body = min(abs(next_position[0] - part[0]) + abs(next_position[1] - part[1]) for part in body_positions)
|
|
||||||
# Berechne die Distanz zum eigenen Schwanz
|
|
||||||
distance_to_tail = abs(next_position[0] - tail_position[0]) + abs(next_position[1] - tail_position[1])
|
|
||||||
# Wähle die maximale Distanz (Körper oder Schwanz)
|
|
||||||
if move_close_to_tail:
|
|
||||||
distance = min(next_position, distance_to_tail)
|
|
||||||
else:
|
|
||||||
distance = max(next_position, distance_to_body)
|
|
||||||
# Update max_distance if a larger distance is found
|
|
||||||
if distance > max_distance:
|
|
||||||
max_distance = distance
|
|
||||||
best_move = direction
|
|
||||||
return best_move if best_move else "up" # Standardbewegung, falls keine bessere gefunden wird
|
|
||||||
|
|
||||||
#TODO: Neat to Implement Function to check if eating the food would kill the snake?
|
|
||||||
def would_eating_the_food_kill_the_snake(self, move:str):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def ensure_escape_route(self, move:str):
|
|
||||||
try:
|
|
||||||
future_position = self.safe_positions[move]
|
|
||||||
except KeyError:
|
|
||||||
for move, pos in self.safe_positions.items():
|
|
||||||
if self.is_near_tail(pos, (self.game_board.get_my_snake_tail()['x'], self.game_board.get_my_snake_tail()['y'])):
|
|
||||||
self.add_calculations({"function": "ensure_escape_route", "move": move, "is_near_tail": True})
|
|
||||||
move = self.move_towards(pos)
|
|
||||||
return move
|
|
||||||
else:
|
|
||||||
path_to_tail = self.find_path_to_tail()
|
|
||||||
if path_to_tail:
|
|
||||||
self.add_calculations({"function": "move_towards", "my_head": self.game_board.get_my_snake_head(), "path_to_tail": path_to_tail, "move": move})
|
|
||||||
move = self.move_towards(path_to_tail[0])
|
|
||||||
|
|
||||||
self.add_calculations({"function": "ensure_escape_route", "move": move, "KeyError": "Snake Coild itself up"})
|
|
||||||
#return move
|
|
||||||
|
|
||||||
# TODO: Fix - Snake Neat to find the best way - Close to the Tail and maybe fill most free cells as posible
|
|
||||||
return move
|
|
||||||
|
|
||||||
def is_near_tail(self, position, tail):
|
|
||||||
return abs(position["x"] - tail[0]) + abs(position["y"] - tail[1]) <= 2
|
|
||||||
|
|
||||||
def a_star_search(self, start, goal, obstacles):
|
|
||||||
# Helper functions
|
|
||||||
def is_position_safe(position):
|
|
||||||
return 0 <= position['x'] < self.game_board.get_width() and 0 <= position['y'] < self.game_board.get_height() and (position['x'], position['y']) not in obstacles
|
|
||||||
|
|
||||||
def get_neighbors(position):
|
|
||||||
neighbors = []
|
|
||||||
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: # links, rechts, oben, unten
|
|
||||||
neighbor = {'x': position['x'] + dx, 'y': position['y'] + dy}
|
|
||||||
if is_position_safe(neighbor):
|
|
||||||
neighbors.append(neighbor)
|
|
||||||
return neighbors
|
|
||||||
|
|
||||||
def heuristic(position, goal):
|
|
||||||
# Verwenden Sie eine Heuristik, die immer positiv ist, selbst wenn das Ziel in der Nähe ist
|
|
||||||
return max(abs(position['x'] - goal['x']), abs(position['y'] - goal['y']))
|
|
||||||
|
|
||||||
# Überprüfen, ob das Ziel direkt neben dem Startpunkt liegt
|
|
||||||
if start == goal or (abs(start['x'] - goal['x']) <= 1 and abs(start['y'] - goal['y']) <= 1):
|
|
||||||
# Wenn das Ziel neben dem Startpunkt liegt, ist der Pfad das Ziel selbst
|
|
||||||
return [goal]
|
|
||||||
|
|
||||||
# Initialize the open and closed list
|
|
||||||
open_set = set([(start['x'], start['y'])])
|
|
||||||
came_from = {}
|
|
||||||
g_score = {(start['x'], start['y']): 0}
|
|
||||||
f_score = {(start['x'], start['y']): heuristic(start, goal)}
|
|
||||||
|
|
||||||
while open_set:
|
|
||||||
current = min(open_set, key=lambda pos: f_score.get(pos, float('inf')))
|
|
||||||
current_dict = {'x': current[0], 'y': current[1]}
|
|
||||||
if current_dict == goal:
|
|
||||||
# Reconstruct the path
|
|
||||||
path = []
|
|
||||||
while current in came_from:
|
|
||||||
current = came_from[current]
|
|
||||||
path.append({'x': current[0], 'y': current[1]})
|
|
||||||
path.reverse()
|
|
||||||
if path and path[0] == start:
|
|
||||||
path.pop(0) # Entferne das erste Element, wenn es dem Start entspricht
|
|
||||||
return path # Return the path as a list of dicts
|
|
||||||
|
|
||||||
open_set.remove(current)
|
|
||||||
for neighbor in get_neighbors(current_dict):
|
|
||||||
neighbor_tuple = (neighbor['x'], neighbor['y'])
|
|
||||||
tentative_g_score = g_score[current] + 1 # Distance between neighbors is always 1
|
|
||||||
if tentative_g_score < g_score.get(neighbor_tuple, float('inf')):
|
|
||||||
came_from[neighbor_tuple] = current
|
|
||||||
g_score[neighbor_tuple] = tentative_g_score
|
|
||||||
f_score[neighbor_tuple] = g_score[neighbor_tuple] + heuristic(neighbor, goal)
|
|
||||||
if neighbor_tuple not in open_set:
|
|
||||||
open_set.add(neighbor_tuple)
|
|
||||||
|
|
||||||
return None # Kein Pfad gefunden
|
|
||||||
|
|
||||||
def find_direction(self):
|
|
||||||
# Beispielhafte Logik zur Auswahl einer Bewegungsrichtung
|
|
||||||
for direction, pos in self.safe_positions.items():
|
|
||||||
next_position = (pos['x'], pos['y'])
|
|
||||||
# Konvertiere safe_positions in eine Liste von Tupeln für den Vergleich
|
|
||||||
safe_positions_tuples = [(pos['x'], pos['y']) for pos in self.safe_positions.values()]
|
|
||||||
if next_position in safe_positions_tuples:
|
|
||||||
return direction
|
|
||||||
return "up" # Standardbewegung, falls keine sichere Position gefunden wird
|
|
||||||
|
|||||||
@@ -3,53 +3,55 @@ from snakes.TemplateSnake import TemplateSnake
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
class DummSnake(TemplateSnake):
|
class DummSnake(TemplateSnake):
|
||||||
def choose_move(self, data: dict) -> str:
|
VERSION = "1.0.0"
|
||||||
is_move_safe = {"up": True, "down": True, "left": True, "right": True}
|
|
||||||
|
|
||||||
# We've included code to prevent your Battlesnake from moving backwards
|
def choose_move(self, data: dict) -> str:
|
||||||
my_head = data["you"]["body"][0] # Coordinates of your head
|
is_move_safe = {"up": True, "down": True, "left": True, "right": True}
|
||||||
my_neck = data["you"]["body"][1] # Coordinates of your "neck"
|
|
||||||
|
|
||||||
if my_neck["x"] < my_head["x"]: # Neck is left of head, don't move left
|
# We've included code to prevent your Battlesnake from moving backwards
|
||||||
is_move_safe["left"] = False
|
my_head = data["you"]["body"][0] # Coordinates of your head
|
||||||
|
my_neck = data["you"]["body"][1] # Coordinates of your "neck"
|
||||||
|
|
||||||
elif my_neck["x"] > my_head["x"]: # Neck is right of head, don't move right
|
if my_neck["x"] < my_head["x"]: # Neck is left of head, don't move left
|
||||||
is_move_safe["right"] = False
|
is_move_safe["left"] = False
|
||||||
|
|
||||||
elif my_neck["y"] < my_head["y"]: # Neck is below head, don't move down
|
elif my_neck["x"] > my_head["x"]: # Neck is right of head, don't move right
|
||||||
is_move_safe["down"] = False
|
is_move_safe["right"] = False
|
||||||
|
|
||||||
elif my_neck["y"] > my_head["y"]: # Neck is above head, don't move up
|
elif my_neck["y"] < my_head["y"]: # Neck is below head, don't move down
|
||||||
is_move_safe["up"] = False
|
is_move_safe["down"] = False
|
||||||
|
|
||||||
# TODO: Step 1 - Prevent your Battlesnake from moving out of bounds
|
elif my_neck["y"] > my_head["y"]: # Neck is above head, don't move up
|
||||||
# board_width = game_state['board']['width']
|
is_move_safe["up"] = False
|
||||||
# board_height = game_state['board']['height']
|
|
||||||
|
|
||||||
# TODO: Step 2 - Prevent your Battlesnake from colliding with itself
|
# TODO: Step 1 - Prevent your Battlesnake from moving out of bounds
|
||||||
# my_body = game_state['you']['body']
|
# board_width = game_state['board']['width']
|
||||||
|
# board_height = game_state['board']['height']
|
||||||
|
|
||||||
# TODO: Step 3 - Prevent your Battlesnake from colliding with other Battlesnakes
|
# TODO: Step 2 - Prevent your Battlesnake from colliding with itself
|
||||||
# opponents = game_state['board']['snakes']
|
# my_body = game_state['you']['body']
|
||||||
|
|
||||||
# Are there any safe moves left?
|
# TODO: Step 3 - Prevent your Battlesnake from colliding with other Battlesnakes
|
||||||
safe_moves = []
|
# opponents = game_state['board']['snakes']
|
||||||
for move, isSafe in is_move_safe.items():
|
|
||||||
if isSafe:
|
|
||||||
safe_moves.append(move)
|
|
||||||
|
|
||||||
if len(safe_moves) == 0:
|
# Are there any safe moves left?
|
||||||
print(f"MOVE {data['turn']}: No safe moves detected! Moving down")
|
safe_moves = []
|
||||||
self.add_to_history({"my_head": my_head, "my_neck": my_neck, "move": move, "safe_moves": safe_moves, "is_move_safe": is_move_safe})
|
for move, isSafe in is_move_safe.items():
|
||||||
return {"move": "down"}
|
if isSafe:
|
||||||
|
safe_moves.append(move)
|
||||||
|
|
||||||
# Choose a random move from the safe ones
|
if len(safe_moves) == 0:
|
||||||
move = random.choice(safe_moves)
|
print(f"MOVE {data['turn']}: No safe moves detected! Moving down")
|
||||||
|
self.add_to_history({"my_head": my_head, "my_neck": my_neck, "move": move, "safe_moves": safe_moves, "is_move_safe": is_move_safe})
|
||||||
|
return {"move": "down"}
|
||||||
|
|
||||||
# TODO: Step 4 - Move towards food instead of random, to regain health and survive longer
|
# Choose a random move from the safe ones
|
||||||
# food = game_state['board']['food']
|
move = random.choice(safe_moves)
|
||||||
|
|
||||||
self.add_to_history({"my_head": my_head, "my_neck": my_neck, "move": move, "safe_moves": safe_moves, "is_move_safe": is_move_safe})
|
# TODO: Step 4 - Move towards food instead of random, to regain health and survive longer
|
||||||
print(f"{data['game']['id']} MOVE {data['turn']}: {move} picked from all valid options in {is_move_safe}")
|
# food = game_state['board']['food']
|
||||||
|
|
||||||
return move
|
self.add_to_history({"my_head": my_head, "my_neck": my_neck, "move": move, "safe_moves": safe_moves, "is_move_safe": is_move_safe})
|
||||||
|
print(f"{data['game']['id']} MOVE {data['turn']}: {move} picked from all valid options in {is_move_safe}")
|
||||||
|
|
||||||
|
return move
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import random
|
|||||||
from scipy import spatial
|
from scipy import spatial
|
||||||
|
|
||||||
class LogicSnake(TemplateSnake):
|
class LogicSnake(TemplateSnake):
|
||||||
|
VERSION = "1.1.0"
|
||||||
|
|
||||||
def avoid_my_body(self, my_body, possible_moves: dict) -> list:
|
def avoid_my_body(self, my_body, possible_moves: dict) -> list:
|
||||||
"""
|
"""
|
||||||
my_body: List of dictionaries of x/y coordinates for every segment of a Battlesnake.
|
my_body: List of dictionaries of x/y coordinates for every segment of a Battlesnake.
|
||||||
|
|||||||
@@ -1,246 +1,249 @@
|
|||||||
from snakes.TemplateSnake import TemplateSnake
|
from snakes.TemplateSnake import TemplateSnake
|
||||||
|
|
||||||
class MasterSnake(TemplateSnake):
|
class MasterSnake(TemplateSnake):
|
||||||
def __init__(self):
|
VERSION = "1.2.0"
|
||||||
super().__init__()
|
|
||||||
self.name = "MasterSnake"
|
|
||||||
self.disabled_find_near_by_food = True
|
|
||||||
|
|
||||||
def is_food_nearby(self, head, food_positions):
|
def __init__(self):
|
||||||
for food in food_positions:
|
super().__init__()
|
||||||
if abs(head['x'] - food['x']) <= 1 and abs(head['y'] - food['y']) <= 1:
|
self.name = "MasterSnake"
|
||||||
return True
|
self.version = self.VERSION
|
||||||
return False
|
self.disabled_find_near_by_food = True
|
||||||
|
|
||||||
def avoid_snake_body(self, snakes, board_width, board_height):
|
def is_food_nearby(self, head, food_positions):
|
||||||
# Konvertiere die Körperpositionen der Schlangen in ein Set von Tupeln für schnellen Zugriff
|
for food in food_positions:
|
||||||
body_positions = set()
|
if abs(head['x'] - food['x']) <= 1 and abs(head['y'] - food['y']) <= 1:
|
||||||
for snake in snakes:
|
return True
|
||||||
for part in snake['body']:
|
return False
|
||||||
body_positions.add((part['x'], part['y']))
|
|
||||||
|
|
||||||
# Implementiere die Logik, um Positionen zu finden, die nicht von Schlangenkörpern belegt sind
|
def avoid_snake_body(self, snakes, board_width, board_height):
|
||||||
safe_positions = self.find_safe_positions(body_positions, board_width, board_height)
|
# Konvertiere die Körperpositionen der Schlangen in ein Set von Tupeln für schnellen Zugriff
|
||||||
return safe_positions
|
body_positions = set()
|
||||||
|
for snake in snakes:
|
||||||
|
for part in snake['body']:
|
||||||
|
body_positions.add((part['x'], part['y']))
|
||||||
|
|
||||||
def find_safe_positions(self, body_positions, board_width, board_height):
|
# Implementiere die Logik, um Positionen zu finden, die nicht von Schlangenkörpern belegt sind
|
||||||
# Finde sichere Positionen basierend auf den Körperpositionen und der Größe des Spielbretts
|
safe_positions = self.find_safe_positions(body_positions, board_width, board_height)
|
||||||
safe_positions = []
|
return safe_positions
|
||||||
for x in range(board_width): # Nutze die tatsächliche Breite des Spielbretts
|
|
||||||
for y in range(board_height): # Nutze die tatsächliche Höhe des Spielbretts
|
|
||||||
if (x, y) not in body_positions:
|
|
||||||
safe_positions.append({'x': x, 'y': y})
|
|
||||||
return safe_positions
|
|
||||||
|
|
||||||
def choose_move(self, game_data):
|
def find_safe_positions(self, body_positions, board_width, board_height):
|
||||||
board_width = game_data['board']['width']
|
# Finde sichere Positionen basierend auf den Körperpositionen und der Größe des Spielbretts
|
||||||
board_height = game_data['board']['height']
|
safe_positions = []
|
||||||
snakes = game_data['board']['snakes']
|
for x in range(board_width): # Nutze die tatsächliche Breite des Spielbretts
|
||||||
my_snake = game_data['you']
|
for y in range(board_height): # Nutze die tatsächliche Höhe des Spielbretts
|
||||||
my_head = my_snake['head']
|
if (x, y) not in body_positions:
|
||||||
|
safe_positions.append({'x': x, 'y': y})
|
||||||
|
return safe_positions
|
||||||
|
|
||||||
# Vermeide Schlangenkörper
|
def choose_move(self, game_data):
|
||||||
safe_positions = self.avoid_snake_body(snakes, board_width, board_height)
|
board_width = game_data['board']['width']
|
||||||
|
board_height = game_data['board']['height']
|
||||||
|
snakes = game_data['board']['snakes']
|
||||||
|
my_snake = game_data['you']
|
||||||
|
my_head = my_snake['head']
|
||||||
|
|
||||||
# Finde die nächstgelegene Nahrungsquelle, wenn Nahrung vorhanden ist
|
# Vermeide Schlangenkörper
|
||||||
try:
|
safe_positions = self.avoid_snake_body(snakes, board_width, board_height)
|
||||||
if self.is_food_nearby(my_head, game_data['board']['food']) or self.disabled_find_near_by_food:
|
|
||||||
path_to_food = self.find_path_to_food(game_data)
|
|
||||||
if path_to_food:
|
|
||||||
# Implementiere Logik, um in Richtung der Nahrungsquelle zu bewegen, falls sicher
|
|
||||||
move = self.move_towards(my_head, path_to_food[0], safe_positions)
|
|
||||||
self.add_to_history({"my_head": my_head, "path_to_food": path_to_food, "move": move})
|
|
||||||
else:
|
|
||||||
# Einfache Logik, um eine Bewegungsrichtung zu wählen, wenn keine Nahrung vorhanden ist
|
|
||||||
move = self.find_direction(my_head, safe_positions)
|
|
||||||
self.add_to_history({"my_head": my_head, "move": move})
|
|
||||||
else:
|
|
||||||
# Wenn keine Nahrung in der Nähe ist, bewege dich in eine Richtung, die dich nahe an deinem eigenen Körper hält
|
|
||||||
move = self.find_direction(my_head, safe_positions)
|
|
||||||
self.add_to_history({"my_head": my_head, "move": move})
|
|
||||||
except ValueError:
|
|
||||||
move = self.find_direction(my_head, safe_positions)
|
|
||||||
self.add_to_history({"my_head": my_head, "move": move})
|
|
||||||
|
|
||||||
# Finde den größten sicheren Bereich
|
# Finde die nächstgelegene Nahrungsquelle, wenn Nahrung vorhanden ist
|
||||||
max_area_start, max_area = self.flood_fill(my_head, safe_positions)
|
try:
|
||||||
# Wenn der Schwanz der Schlange im größten sicheren Bereich liegt, bewege dich in Richtung des Schwanzes
|
if self.is_food_nearby(my_head, game_data['board']['food']) or self.disabled_find_near_by_food:
|
||||||
my_tail = (my_snake['body'][-1]['x'], my_snake['body'][-1]['y']) # Convert to tuple
|
path_to_food = self.find_path_to_food(game_data)
|
||||||
if my_tail in max_area:
|
if path_to_food:
|
||||||
move = self.move_towards(my_head, my_tail, safe_positions)
|
# Implementiere Logik, um in Richtung der Nahrungsquelle zu bewegen, falls sicher
|
||||||
|
move = self.move_towards(my_head, path_to_food[0], safe_positions)
|
||||||
# Überprüfe zukünftige Bewegungen, um Sackgassen zu vermeiden
|
self.add_to_history({"my_head": my_head, "path_to_food": path_to_food, "move": move})
|
||||||
move = self.avoid_dead_ends(my_head, move, safe_positions, snakes)
|
else:
|
||||||
|
# Einfache Logik, um eine Bewegungsrichtung zu wählen, wenn keine Nahrung vorhanden ist
|
||||||
|
move = self.find_direction(my_head, safe_positions)
|
||||||
|
self.add_to_history({"my_head": my_head, "move": move})
|
||||||
|
else:
|
||||||
|
# Wenn keine Nahrung in der Nähe ist, bewege dich in eine Richtung, die dich nahe an deinem eigenen Körper hält
|
||||||
|
move = self.find_direction(my_head, safe_positions)
|
||||||
self.add_to_history({"my_head": my_head, "move": move})
|
self.add_to_history({"my_head": my_head, "move": move})
|
||||||
|
except ValueError:
|
||||||
|
move = self.find_direction(my_head, safe_positions)
|
||||||
|
self.add_to_history({"my_head": my_head, "move": move})
|
||||||
|
|
||||||
return move
|
# Finde den größten sicheren Bereich
|
||||||
|
max_area_start, max_area = self.flood_fill(my_head, safe_positions)
|
||||||
|
# Wenn der Schwanz der Schlange im größten sicheren Bereich liegt, bewege dich in Richtung des Schwanzes
|
||||||
|
my_tail = (my_snake['body'][-1]['x'], my_snake['body'][-1]['y']) # Convert to tuple
|
||||||
|
if my_tail in max_area:
|
||||||
|
move = self.move_towards(my_head, my_tail, safe_positions)
|
||||||
|
|
||||||
def move_towards(self, head, target, safe_positions):
|
# Überprüfe zukünftige Bewegungen, um Sackgassen zu vermeiden
|
||||||
directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)}
|
move = self.avoid_dead_ends(my_head, move, safe_positions, snakes)
|
||||||
best_direction = None
|
self.add_to_history({"my_head": my_head, "move": move})
|
||||||
min_distance = float('inf')
|
|
||||||
min_distance_to_body = float('inf')
|
|
||||||
body_positions = set((pos['x'], pos['y']) for pos in safe_positions[:-1]) # Exclude the head from body positions
|
|
||||||
|
|
||||||
for direction, (dx, dy) in directions.items():
|
return move
|
||||||
next_position = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
|
||||||
if next_position in safe_positions:
|
|
||||||
distance = abs(target[0] - next_position['x']) + abs(target[1] - next_position['y'])
|
|
||||||
distance_to_body = sum(abs(part[0] - next_position['x']) + abs(part[1] - next_position['y']) for part in body_positions)
|
|
||||||
if distance < min_distance or (distance == min_distance and distance_to_body < min_distance_to_body):
|
|
||||||
best_direction = direction
|
|
||||||
min_distance = distance
|
|
||||||
min_distance_to_body = distance_to_body
|
|
||||||
|
|
||||||
return best_direction if best_direction else "up" # Default to moving up if no safe direction found
|
def move_towards(self, head, target, safe_positions):
|
||||||
|
directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)}
|
||||||
|
best_direction = None
|
||||||
|
min_distance = float('inf')
|
||||||
|
min_distance_to_body = float('inf')
|
||||||
|
body_positions = set((pos['x'], pos['y']) for pos in safe_positions[:-1]) # Exclude the head from body positions
|
||||||
|
|
||||||
def find_path_to_food(self, game_data):
|
for direction, (dx, dy) in directions.items():
|
||||||
my_head = game_data['you']['head']
|
next_position = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
||||||
food_positions = game_data['board']['food']
|
if next_position in safe_positions:
|
||||||
snakes = game_data['board']['snakes']
|
distance = abs(target[0] - next_position['x']) + abs(target[1] - next_position['y'])
|
||||||
board_width = game_data['board']['width']
|
distance_to_body = sum(abs(part[0] - next_position['x']) + abs(part[1] - next_position['y']) for part in body_positions)
|
||||||
board_height = game_data['board']['height']
|
if distance < min_distance or (distance == min_distance and distance_to_body < min_distance_to_body):
|
||||||
|
best_direction = direction
|
||||||
|
min_distance = distance
|
||||||
|
min_distance_to_body = distance_to_body
|
||||||
|
|
||||||
# Exclude own snake's body from obstacles
|
return best_direction if best_direction else "up" # Default to moving up if no safe direction found
|
||||||
own_snake_body = game_data['you']['body']
|
|
||||||
obstacles = set((part['x'], part['y']) for part in own_snake_body)
|
|
||||||
|
|
||||||
for snake in snakes:
|
def find_path_to_food(self, game_data):
|
||||||
if snake['id'] != game_data['you']['id']:
|
my_head = game_data['you']['head']
|
||||||
for part in snake['body']:
|
food_positions = game_data['board']['food']
|
||||||
obstacles.add((part['x'], part['y']))
|
snakes = game_data['board']['snakes']
|
||||||
|
board_width = game_data['board']['width']
|
||||||
|
board_height = game_data['board']['height']
|
||||||
|
|
||||||
# Choose the closest food source based on the heuristic
|
# Exclude own snake's body from obstacles
|
||||||
closest_food = min(food_positions, key=lambda food: abs(food['x'] - my_head['x']) + abs(food['y'] - my_head['y']))
|
own_snake_body = game_data['you']['body']
|
||||||
|
obstacles = set((part['x'], part['y']) for part in own_snake_body)
|
||||||
|
|
||||||
# Use A* to search for a safe path
|
for snake in snakes:
|
||||||
path = self.a_star_search(my_head, closest_food, obstacles, board_width, board_height)
|
if snake['id'] != game_data['you']['id']:
|
||||||
return path
|
for part in snake['body']:
|
||||||
|
obstacles.add((part['x'], part['y']))
|
||||||
|
|
||||||
def a_star_search(self, start, goal, obstacles, board_width, board_height):
|
# Choose the closest food source based on the heuristic
|
||||||
# Convert snake positions into a set of obstacles
|
closest_food = min(food_positions, key=lambda food: abs(food['x'] - my_head['x']) + abs(food['y'] - my_head['y']))
|
||||||
# Helper functions
|
|
||||||
def is_position_safe(position):
|
|
||||||
x, y = position
|
|
||||||
return 0 <= x < board_width and 0 <= y < board_height and position not in obstacles
|
|
||||||
|
|
||||||
def get_neighbors(position):
|
# Use A* to search for a safe path
|
||||||
x, y = position
|
path = self.a_star_search(my_head, closest_food, obstacles, board_width, board_height)
|
||||||
return [(nx, ny) for nx, ny in [(x-1, y), (x+1, y), (x, y-1), (x, y+1)] if is_position_safe((nx, ny))]
|
return path
|
||||||
|
|
||||||
def heuristic(position, goal):
|
def a_star_search(self, start, goal, obstacles, board_width, board_height):
|
||||||
return abs(position[0] - goal[0]) + abs(position[1] - goal[1])
|
# Convert snake positions into a set of obstacles
|
||||||
|
# Helper functions
|
||||||
|
def is_position_safe(position):
|
||||||
|
x, y = position
|
||||||
|
return 0 <= x < board_width and 0 <= y < board_height and position not in obstacles
|
||||||
|
|
||||||
# Initialize start and goal positions
|
def get_neighbors(position):
|
||||||
start = (start['x'], start['y'])
|
x, y = position
|
||||||
goal = (goal['x'], goal['y'])
|
return [(nx, ny) for nx, ny in [(x-1, y), (x+1, y), (x, y-1), (x, y+1)] if is_position_safe((nx, ny))]
|
||||||
|
|
||||||
# Initialize the open and closed list
|
def heuristic(position, goal):
|
||||||
open_set = set([start])
|
return abs(position[0] - goal[0]) + abs(position[1] - goal[1])
|
||||||
came_from = {}
|
|
||||||
g_score = {start: 0}
|
|
||||||
f_score = {start: heuristic(start, goal)}
|
|
||||||
|
|
||||||
while open_set:
|
# Initialize start and goal positions
|
||||||
current = min(open_set, key=lambda pos: f_score.get(pos, float('inf')))
|
start = (start['x'], start['y'])
|
||||||
if current == goal:
|
goal = (goal['x'], goal['y'])
|
||||||
# Reconstruct the path
|
|
||||||
path = []
|
|
||||||
while current in came_from:
|
|
||||||
path.append(current)
|
|
||||||
current = came_from[current]
|
|
||||||
path.reverse()
|
|
||||||
return path # Return the path as a list of tuples
|
|
||||||
|
|
||||||
open_set.remove(current)
|
# Initialize the open and closed list
|
||||||
for neighbor in get_neighbors(current):
|
open_set = set([start])
|
||||||
tentative_g_score = g_score[current] + 1 # Distance between neighbors is always 1
|
came_from = {}
|
||||||
if tentative_g_score < g_score.get(neighbor, float('inf')):
|
g_score = {start: 0}
|
||||||
came_from[neighbor] = current
|
f_score = {start: heuristic(start, goal)}
|
||||||
g_score[neighbor] = tentative_g_score
|
|
||||||
f_score[neighbor] = g_score[neighbor] + heuristic(neighbor, goal)
|
|
||||||
if neighbor not in open_set:
|
|
||||||
open_set.add(neighbor)
|
|
||||||
|
|
||||||
return None # Kein Pfad gefunden
|
while open_set:
|
||||||
|
current = min(open_set, key=lambda pos: f_score.get(pos, float('inf')))
|
||||||
|
if current == goal:
|
||||||
|
# Reconstruct the path
|
||||||
|
path = []
|
||||||
|
while current in came_from:
|
||||||
|
path.append(current)
|
||||||
|
current = came_from[current]
|
||||||
|
path.reverse()
|
||||||
|
return path # Return the path as a list of tuples
|
||||||
|
|
||||||
def find_direction(self, head, safe_positions):
|
open_set.remove(current)
|
||||||
# Beispielhafte Logik zur Auswahl einer Bewegungsrichtung
|
for neighbor in get_neighbors(current):
|
||||||
directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)}
|
tentative_g_score = g_score[current] + 1 # Distance between neighbors is always 1
|
||||||
for direction, (dx, dy) in directions.items():
|
if tentative_g_score < g_score.get(neighbor, float('inf')):
|
||||||
next_position = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
came_from[neighbor] = current
|
||||||
if next_position in safe_positions:
|
g_score[neighbor] = tentative_g_score
|
||||||
return direction
|
f_score[neighbor] = g_score[neighbor] + heuristic(neighbor, goal)
|
||||||
return "up" # Standardbewegung, falls keine sichere Position gefunden wird
|
if neighbor not in open_set:
|
||||||
|
open_set.add(neighbor)
|
||||||
|
|
||||||
def avoid_self_collision(self, future_head, body_positions):
|
return None # Kein Pfad gefunden
|
||||||
# Überprüft, ob die zukünftige Kopfposition im Körper der Schlange liegt
|
|
||||||
return (future_head['x'], future_head['y']) not in body_positions
|
|
||||||
|
|
||||||
def avoid_dead_ends(self, head, move, safe_positions, snakes):
|
def find_direction(self, head, safe_positions):
|
||||||
directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)}
|
# Beispielhafte Logik zur Auswahl einer Bewegungsrichtung
|
||||||
dx, dy = directions[move]
|
directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)}
|
||||||
future_head = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
for direction, (dx, dy) in directions.items():
|
||||||
body_positions = set((part['x'], part['y']) for part in snakes[0]['body'])
|
next_position = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
||||||
|
if next_position in safe_positions:
|
||||||
|
return direction
|
||||||
|
return "up" # Standardbewegung, falls keine sichere Position gefunden wird
|
||||||
|
|
||||||
if not self.is_future_move_safe(future_head, safe_positions, snakes) or not self.avoid_self_collision(future_head, body_positions):
|
def avoid_self_collision(self, future_head, body_positions):
|
||||||
for alternative_move in directions.keys():
|
# Überprüft, ob die zukünftige Kopfposition im Körper der Schlange liegt
|
||||||
dx, dy = directions[alternative_move]
|
return (future_head['x'], future_head['y']) not in body_positions
|
||||||
alternative_future_head = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
|
||||||
if self.is_future_move_safe(alternative_future_head, safe_positions, snakes) and self.avoid_self_collision(alternative_future_head, body_positions):
|
|
||||||
return alternative_move
|
|
||||||
return move
|
|
||||||
|
|
||||||
def simulate_snake_movement(self, snakes):
|
def avoid_dead_ends(self, head, move, safe_positions, snakes):
|
||||||
future_body_positions = set()
|
directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)}
|
||||||
for snake in snakes:
|
dx, dy = directions[move]
|
||||||
# Beachte, dass dies nur ein Beispiel ist und angepasst werden muss, um deine spezifische Spiellogik zu berücksichtigen
|
future_head = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
||||||
for part in snake['body'][:-1]: # Ignoriere den letzten Teil des Körpers, da er sich bewegt
|
body_positions = set((part['x'], part['y']) for part in snakes[0]['body'])
|
||||||
future_body_positions.add((part['x'], part['y']))
|
|
||||||
return future_body_positions
|
|
||||||
|
|
||||||
def is_future_move_safe(self, future_head, safe_positions, snakes):
|
if not self.is_future_move_safe(future_head, safe_positions, snakes) or not self.avoid_self_collision(future_head, body_positions):
|
||||||
# Simuliere die Bewegung der Schlange und aktualisiere die Positionen des eigenen Körpers
|
for alternative_move in directions.keys():
|
||||||
future_body_positions = self.simulate_snake_movement(snakes)
|
dx, dy = directions[alternative_move]
|
||||||
# Konvertiere safe_positions in ein Set von Tupeln für den Flood Fill Algorithmus
|
alternative_future_head = {'x': head['x'] + dx, 'y': head['y'] + dy}
|
||||||
safe_positions_set = set((pos['x'], pos['y']) for pos in safe_positions)
|
if self.is_future_move_safe(alternative_future_head, safe_positions, snakes) and self.avoid_self_collision(alternative_future_head, body_positions):
|
||||||
# Entferne die zukünftigen Körperpositionen aus den sicheren Positionen
|
return alternative_move
|
||||||
safe_positions_set = safe_positions_set - future_body_positions
|
return move
|
||||||
# Füge die zukünftige Kopfposition hinzu, um sie als Startpunkt zu verwenden
|
|
||||||
safe_positions_set.add((future_head['x'], future_head['y']))
|
|
||||||
# Berechne die Anzahl der erreichbaren sicheren Positionen von der zukünftigen Kopfposition aus
|
|
||||||
reachable_positions = self.flood_fill((future_head['x'], future_head['y']), safe_positions_set)
|
|
||||||
# Entscheide, ob die Bewegung sicher ist, basierend auf der Anzahl der erreichbaren Positionen
|
|
||||||
|
|
||||||
fill_bool = len(reachable_positions) > len(safe_positions_set) * 0.25
|
def simulate_snake_movement(self, snakes):
|
||||||
if fill_bool:
|
future_body_positions = set()
|
||||||
return fill_bool
|
for snake in snakes:
|
||||||
|
# Beachte, dass dies nur ein Beispiel ist und angepasst werden muss, um deine spezifische Spiellogik zu berücksichtigen
|
||||||
|
for part in snake['body'][:-1]: # Ignoriere den letzten Teil des Körpers, da er sich bewegt
|
||||||
|
future_body_positions.add((part['x'], part['y']))
|
||||||
|
return future_body_positions
|
||||||
|
|
||||||
return len(safe_positions_set) >= len(snakes[0]['body'])
|
def is_future_move_safe(self, future_head, safe_positions, snakes):
|
||||||
|
# Simuliere die Bewegung der Schlange und aktualisiere die Positionen des eigenen Körpers
|
||||||
|
future_body_positions = self.simulate_snake_movement(snakes)
|
||||||
|
# Konvertiere safe_positions in ein Set von Tupeln für den Flood Fill Algorithmus
|
||||||
|
safe_positions_set = set((pos['x'], pos['y']) for pos in safe_positions)
|
||||||
|
# Entferne die zukünftigen Körperpositionen aus den sicheren Positionen
|
||||||
|
safe_positions_set = safe_positions_set - future_body_positions
|
||||||
|
# Füge die zukünftige Kopfposition hinzu, um sie als Startpunkt zu verwenden
|
||||||
|
safe_positions_set.add((future_head['x'], future_head['y']))
|
||||||
|
# Berechne die Anzahl der erreichbaren sicheren Positionen von der zukünftigen Kopfposition aus
|
||||||
|
reachable_positions = self.flood_fill((future_head['x'], future_head['y']), safe_positions_set)
|
||||||
|
# Entscheide, ob die Bewegung sicher ist, basierend auf der Anzahl der erreichbaren Positionen
|
||||||
|
|
||||||
def flood_fill(self, start, safe_positions):
|
fill_bool = len(reachable_positions) > len(safe_positions_set) * 0.25
|
||||||
stack = [start]
|
if fill_bool:
|
||||||
visited = set()
|
return fill_bool
|
||||||
max_area = 0
|
|
||||||
max_area_start = None
|
|
||||||
|
|
||||||
while stack:
|
return len(safe_positions_set) >= len(snakes[0]['body'])
|
||||||
position = stack.pop()
|
|
||||||
if isinstance(position, dict):
|
|
||||||
position = tuple(position.values())
|
|
||||||
else:
|
|
||||||
position = tuple(position)
|
|
||||||
|
|
||||||
if position not in visited:
|
def flood_fill(self, start, safe_positions):
|
||||||
visited.add(position)
|
stack = [start]
|
||||||
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: # links, rechts, oben, unten
|
visited = set()
|
||||||
next_position = tuple([position[0] + dx, position[1] + dy])
|
max_area = 0
|
||||||
if next_position in safe_positions:
|
max_area_start = None
|
||||||
stack.append(next_position)
|
|
||||||
|
|
||||||
# Überprüfe, ob der aktuelle Bereich größer ist als der bisher größte Bereich
|
while stack:
|
||||||
if len(visited) > max_area:
|
position = stack.pop()
|
||||||
max_area = len(visited)
|
if isinstance(position, dict):
|
||||||
max_area_start = position
|
position = tuple(position.values())
|
||||||
|
else:
|
||||||
|
position = tuple(position)
|
||||||
|
|
||||||
return max_area_start, visited
|
if position not in visited:
|
||||||
|
visited.add(position)
|
||||||
|
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: # links, rechts, oben, unten
|
||||||
|
next_position = tuple([position[0] + dx, position[1] + dy])
|
||||||
|
if next_position in safe_positions:
|
||||||
|
stack.append(next_position)
|
||||||
|
|
||||||
|
# Überprüfe, ob der aktuelle Bereich größer ist als der bisher größte Bereich
|
||||||
|
if len(visited) > max_area:
|
||||||
|
max_area = len(visited)
|
||||||
|
max_area_start = position
|
||||||
|
|
||||||
|
return max_area_start, visited
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
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:
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.history = []
|
self.history = []
|
||||||
self.target_food = None
|
self.target_food = None
|
||||||
|
self.name = self.__class__.__name__
|
||||||
|
self.version = getattr(self, "VERSION", "1.0.0")
|
||||||
|
|
||||||
def clear_history(self):
|
def clear_history(self):
|
||||||
self.history = []
|
self.history = []
|
||||||
@@ -18,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
|
||||||
@@ -194,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
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
import random, json, os
|
||||||
|
|
||||||
|
from server.TrainBattleSnakeAI import MOVES, extract_feature_values
|
||||||
|
from snakes.TemplateSnake import TemplateSnake
|
||||||
|
|
||||||
|
class TrainedBattleSnake(TemplateSnake):
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.name = "TrainedBattleSnake"
|
||||||
|
self.version = self.VERSION
|
||||||
|
self._model_path:Path|None=None
|
||||||
|
self._model_data:dict[str, Any]|None=None
|
||||||
|
|
||||||
|
def choose_move(self, game_data) -> str:
|
||||||
|
self.game_board = game_data
|
||||||
|
self.calculations = []
|
||||||
|
|
||||||
|
safe_positions = self.find_safe_positions(add_to_calculations=True)
|
||||||
|
if not safe_positions:
|
||||||
|
self.add_to_history({"turn": game_data.get_turn(), "reason": "no_safe_moves"})
|
||||||
|
return "up"
|
||||||
|
|
||||||
|
model = self._load_model()
|
||||||
|
if not model:
|
||||||
|
move = random.choice(list(safe_positions.keys()))
|
||||||
|
self.add_to_history({
|
||||||
|
"turn": game_data.get_turn(),
|
||||||
|
"move": move,
|
||||||
|
"reason": "model_missing",
|
||||||
|
"safe_moves": list(safe_positions.keys()),
|
||||||
|
})
|
||||||
|
return move
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"turn": game_data.get_turn(),
|
||||||
|
"game_board": game_data.get_game_board_as_dict(),
|
||||||
|
}
|
||||||
|
scores = self._predict_scores(model, row)
|
||||||
|
|
||||||
|
best_safe_move = max(safe_positions.keys(), key=lambda move: scores.get(move, float("-inf")))
|
||||||
|
self.add_to_history({
|
||||||
|
"turn": game_data.get_turn(),
|
||||||
|
"move": best_safe_move,
|
||||||
|
"safe_moves": list(safe_positions.keys()),
|
||||||
|
"scores": {move: round(scores.get(move, 0.0), 5) for move in MOVES},
|
||||||
|
})
|
||||||
|
return best_safe_move
|
||||||
|
|
||||||
|
def _load_model(self) -> dict[str, Any] | None:
|
||||||
|
env_path = os.getenv("TRAINED_SNAKE_MODEL", "models/battlesnake_softmax_v2.json")
|
||||||
|
path = Path(env_path)
|
||||||
|
|
||||||
|
if self._model_path == path and self._model_data is not None:
|
||||||
|
return self._model_data
|
||||||
|
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
self._model_path = path
|
||||||
|
self._model_data = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
model = payload.get("model")
|
||||||
|
if not isinstance(model, dict):
|
||||||
|
self._model_path = path
|
||||||
|
self._model_data = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._model_path = path
|
||||||
|
self._model_data = model
|
||||||
|
return model
|
||||||
|
|
||||||
|
def _predict_scores(self, model:dict[str, Any], row:dict[str, Any]) -> dict[str, float]:
|
||||||
|
return self._predict_scores_softmax_v2(model, row)
|
||||||
|
|
||||||
|
def _predict_scores_softmax_v2(self, model:dict[str, Any], row:dict[str, Any]) -> dict[str, float]:
|
||||||
|
features = extract_feature_values(row)
|
||||||
|
weights = model.get("weights", {})
|
||||||
|
bias = model.get("bias", {})
|
||||||
|
scores:dict[str, float] = {}
|
||||||
|
|
||||||
|
for move in MOVES:
|
||||||
|
move_weights = weights.get(move, {})
|
||||||
|
score = float(bias.get(move, 0.0))
|
||||||
|
for name, value in features.items():
|
||||||
|
score += float(move_weights.get(name, 0.0)) * float(value)
|
||||||
|
scores[move] = score
|
||||||
|
return scores
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
SNAKE_REGISTRY = {
|
||||||
|
"TemplateSnake": "1.0.0",
|
||||||
|
"DummSnake": "1.0.0",
|
||||||
|
"LogicSnake": "1.1.0",
|
||||||
|
"MasterSnake": "1.2.0",
|
||||||
|
"BetterMasterSnake": "1.3.0",
|
||||||
|
"BestBattleSnake": "2.6.0",
|
||||||
|
"TrainedBattleSnake": "0.1.0",
|
||||||
|
"UltimateBattleSnake": "4.5.0",
|
||||||
|
"ApexBattleSnake": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ValueError(f"Unknown snake: {selected_snake}")
|
||||||
|
|
||||||
|
snake_module = importlib.import_module(f"snakes.{selected_snake}")
|
||||||
|
snake_class = getattr(snake_module, selected_snake)
|
||||||
|
return snake_class()
|
||||||
|
|
||||||
|
def get_snake_version(selected_snake:str) -> str|None:
|
||||||
|
version = SNAKE_REGISTRY.get(selected_snake)
|
||||||
|
if version is None:
|
||||||
|
return None
|
||||||
|
return str(version)
|
||||||
|
|
||||||
|
class SnakeBuilder:
|
||||||
|
@classmethod
|
||||||
|
def build(self, selected_snake: str):
|
||||||
|
return build_snake(selected_snake)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_version(self, selected_snake:str) -> str|None:
|
||||||
|
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 |