Compare commits
177 Commits
v1.0
..
c4238d19e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
|||
|
6e74b5fb57
|
|||
|
962d8b1043
|
|||
|
8ea9cbdcee
|
|||
|
c458219125
|
|||
|
a7a463ed91
|
|||
|
5c1ef7f05f
|
|||
|
d32568cdf2
|
|||
|
61721a7eb6
|
|||
|
bcc9c71c30
|
|||
|
58bbbf3cbd
|
|||
|
4d515f0784
|
|||
|
8424c324e8
|
|||
|
c5c2652f3a
|
|||
|
0768e7f254
|
|||
|
31d2e7ea55
|
|||
|
4b51ddc84d
|
|||
|
31f5225100
|
|||
|
deb95c6246
|
|||
|
600cde4a3e
|
|||
|
e068fb8614
|
|||
|
80b7c4df89
|
|||
|
da0347731c
|
|||
|
a09c05b6ec
|
|||
|
aba457423e
|
|||
|
bb92715de1
|
|||
|
cf45aa60aa
|
|||
|
a58e9695dd
|
|||
|
b57ae5eab2
|
|||
|
817b970623
|
|||
|
c9e6947758
|
|||
|
4a1fbf2752
|
|||
|
5b8bf0da31
|
|||
|
4a8cb40bde
|
|||
|
c5342c1f4d
|
|||
|
ac7c397093
|
|||
|
7dd46dd72b
|
|||
|
10c7f2656c
|
|||
|
f00efe607f
|
|||
|
c333706b75
|
|||
|
917bd3f6bd
|
|||
|
83bcf4f194
|
|||
|
ef4dca447f
|
|||
|
db3a353090
|
|||
|
1f4d17d42f
|
|||
|
f98430462b
|
|||
|
c26824aeaf
|
|||
|
87690177a5
|
|||
|
8a2a62ef57
|
|||
|
5796ce0a6e
|
|||
|
5522a52227
|
|||
|
5743f5c111
|
|||
|
9950fa1952
|
|||
|
4620ee31eb
|
|||
|
9103e3e139
|
|||
|
04eef9229c
|
|||
|
7cb1fdc57d
|
|||
|
cceded8468
|
|||
|
8c57e48f60
|
|||
|
5ce12d70c1
|
|||
|
950351b407
|
|||
|
12ac257d19
|
|||
|
d4b54d48b9
|
|||
|
034b0e361a
|
|||
|
a57536b7cb
|
|||
|
a606ae6f94
|
|||
|
b364c6454e
|
|||
|
b9a0bca4c6
|
|||
|
ea36d60b4d
|
|||
|
0fe4e6ac83
|
|||
|
93b2c8ba99
|
|||
|
f6db5cb96a
|
|||
|
e3d7cccb64
|
|||
|
0eecbb774b
|
|||
|
9e0a919233
|
|||
|
6d9df32076
|
|||
|
863ca1b277
|
|||
|
16cab3a9ca
|
|||
|
281b52e71d
|
|||
|
38ba576de9
|
|||
|
7457e66339
|
|||
|
b601b378c8
|
|||
|
24e744f705
|
|||
|
39b16a1702
|
|||
|
87fe6550b2
|
|||
|
c854b5fec9
|
|||
|
51108ce21c
|
|||
|
2f8e35aced
|
|||
|
9869f14bbe
|
|||
|
1154127a40
|
|||
|
ae1489240d
|
|||
|
0af6d862d4
|
|||
|
f472ddd0d9
|
@@ -0,0 +1,40 @@
|
||||
name: Build and Push Docker Container
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
token: '${{ secrets.ACTION_ACCESS_TOKEN }}'
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ vars.DOCKER_REGISTRY_URL }}
|
||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.ACTION_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image for latest tag
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ vars.DOCKER_REGISTRY_URL }}/daniel156161/battlesnake:latest
|
||||
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 }}
|
||||
@@ -7,3 +7,10 @@
|
||||
__pycache__/
|
||||
data/
|
||||
.env
|
||||
.tools/
|
||||
|
||||
dbschema/migrations/
|
||||
|
||||
*.jsonl
|
||||
/dataset/
|
||||
models/
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -1,11 +1,13 @@
|
||||
FROM python:3.10.6-slim
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim
|
||||
|
||||
# Install app
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
RUN uv sync --no-config --frozen --compile-bytecode
|
||||
|
||||
# Run Battlesnake
|
||||
CMD [ "python", "main.py" ]
|
||||
# Starten Sie Ihre Anwendung
|
||||
EXPOSE 8000
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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!
|
||||
@@ -0,0 +1,95 @@
|
||||
module default {
|
||||
function is_winner_me(winner: str) -> bool
|
||||
using (winner = "me");
|
||||
|
||||
function gameboard_url(id: uuid) -> str
|
||||
using ("https://play.battlesnake.com/game/" ++ <str>id);
|
||||
|
||||
type GameBoard {
|
||||
overloaded required id: uuid {
|
||||
readonly := true;
|
||||
constraint exclusive;
|
||||
}
|
||||
url := gameboard_url(.id);
|
||||
|
||||
required created_at: datetime {
|
||||
readonly := true;
|
||||
}
|
||||
required turns: int32 {
|
||||
readonly := true;
|
||||
}
|
||||
required map: str {
|
||||
readonly := true;
|
||||
default := "standard";
|
||||
}
|
||||
required single type: GameType {
|
||||
readonly := true;
|
||||
on source delete delete target if orphan;
|
||||
}
|
||||
required single ruleset: Ruleset {
|
||||
readonly := true;
|
||||
on source delete delete target if orphan;
|
||||
}
|
||||
required winner: str {
|
||||
readonly := true;
|
||||
}
|
||||
multi moves: Moves {
|
||||
default := <Moves>{};
|
||||
on source delete delete target;
|
||||
on target delete allow;
|
||||
}
|
||||
required single snake: Snake {
|
||||
readonly := true;
|
||||
on source delete delete target if orphan;
|
||||
}
|
||||
|
||||
is_winner_me := is_winner_me(.winner);
|
||||
has_moves := exists(.moves);
|
||||
}
|
||||
|
||||
type GameType {
|
||||
required name: str {
|
||||
readonly := true;
|
||||
}
|
||||
required is_ladder: bool {
|
||||
readonly := true;
|
||||
}
|
||||
constraint exclusive on ( (.name, .is_ladder) );
|
||||
}
|
||||
|
||||
type Ruleset {
|
||||
required name: str {
|
||||
readonly := true;
|
||||
}
|
||||
required version: str {
|
||||
readonly := true;
|
||||
}
|
||||
required settings: json {
|
||||
readonly := true;
|
||||
}
|
||||
constraint exclusive on ( (.name, .version, .settings) );
|
||||
}
|
||||
|
||||
type Snake {
|
||||
required type: str {
|
||||
readonly := true;
|
||||
}
|
||||
constraint exclusive on ( .type );
|
||||
}
|
||||
|
||||
type Moves {
|
||||
required turn: int32 {
|
||||
readonly := true;
|
||||
}
|
||||
required snake_move: str {
|
||||
readonly := true;
|
||||
}
|
||||
required game_board: json {
|
||||
readonly := true;
|
||||
}
|
||||
calculations: array<json> {
|
||||
readonly := true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -11,4 +11,9 @@ services:
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: Dockerfile
|
||||
#environment:
|
||||
# - SNAKE_COLOR=blue
|
||||
# - SNAKE_HEAD=caffeine
|
||||
# - SNAKE_TAIL=mlh-gene
|
||||
# - STORE_GAME_HISTORY=True
|
||||
restart: always
|
||||
|
||||
@@ -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 := ".tools/battlesnake-cli"
|
||||
BATTLESNAKE_CLI_BIN := ".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()}}/.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="data" output="data/dataset/good_moves.jsonl":
|
||||
python -m server.DatasetExporter --input "{{input}}" --output "{{output}}"
|
||||
|
||||
curate-dataset input="good_moves-*.jsonl" output="data/dataset/best_moves.jsonl" min_turn="6" late_turn="20" max_safe_options="2" min_score="3" append="false" archive="false" archive_dir="":
|
||||
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="dataset/best_moves.jsonl" rl_input="dataset/rl_bootstrap.jsonl" output="models/battlesnake_softmax_v2.json" eval_split="0.2" seed="42" epochs="14" lr="0.08":
|
||||
if [ -f "{{rl_input}}" ]; then python -m server.TrainBattleSnakeAI --input "{{input}}" --input "{{rl_input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; else python -m server.TrainBattleSnakeAI --input "{{input}}" --output "{{output}}" --eval-split "{{eval_split}}" --seed "{{seed}}" --epochs "{{epochs}}" --lr "{{lr}}"; fi
|
||||
|
||||
run-trained model="models/battlesnake_softmax_v2.json" port="8000":
|
||||
TRAINED_SNAKE_MODEL="{{model}}" SNAKE="TrainedBattleSnake" PORT="{{port}}" "{{justfile_directory()}}/main.py"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
#!/usr/bin/env -S uv run --script
|
||||
|
||||
# Welcome to
|
||||
# __________ __ __ .__ __
|
||||
@@ -12,25 +12,20 @@
|
||||
# To get you started we've included code to prevent your Battlesnake from moving backwards.
|
||||
# For more info see docs.battlesnake.com
|
||||
|
||||
from server.Server import Server
|
||||
from server.CreateEnvironmentFile import CreateEnvironmentFile
|
||||
from server.bootstrap import build_run_config, build_server_from_env
|
||||
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
# Start server when `python main.py` is run
|
||||
if __name__ == "__main__":
|
||||
load_dotenv(find_dotenv())
|
||||
if os.environ.get("CREATE_ENV_FILE", None):
|
||||
CreateEnvironmentFile.load_dotenv({
|
||||
"STORE_GAME_HISTORY": True,
|
||||
"DEBUG": True,
|
||||
"SNAKE": "TemplateSnake",
|
||||
})
|
||||
|
||||
server = Server(
|
||||
data_path=os.path.dirname(__file__),
|
||||
snake_type=os.environ.get("SNAKE", "DummSnake"),
|
||||
)
|
||||
|
||||
if os.environ.get("STORE_GAME_HISTORY", None):
|
||||
server.enable_store_game_state()
|
||||
|
||||
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))
|
||||
)
|
||||
server = build_server_from_env(default_snake_type="TemplateSnake")
|
||||
asyncio.run(server.run(**build_run_config()))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[project]
|
||||
name = "snake-python"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiologger>=0.7.0",
|
||||
"dotenv>=0.9.9",
|
||||
"gel>=3.1.0",
|
||||
"redis>=5.2.1",
|
||||
"quart>=0.20.0",
|
||||
]
|
||||
@@ -1,10 +1,23 @@
|
||||
blinker==1.7.0
|
||||
click==8.1.7
|
||||
Flask==3.0.2
|
||||
itsdangerous==2.1.2
|
||||
Jinja2==3.1.3
|
||||
MarkupSafe==2.1.5
|
||||
numpy==1.26.4
|
||||
python-dotenv==1.0.1
|
||||
scipy==1.12.0
|
||||
Werkzeug==3.0.1
|
||||
aiofiles==25.1.0
|
||||
aiologger==0.7.0
|
||||
async-timeout==5.0.1
|
||||
blinker==1.9.0
|
||||
click==8.3.2
|
||||
dotenv==0.9.9
|
||||
flask==3.1.3
|
||||
gel==3.1.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
hpack==4.1.0
|
||||
hypercorn==0.18.0
|
||||
hyperframe==6.1.0
|
||||
itsdangerous==2.2.0
|
||||
jinja2==3.1.6
|
||||
markupsafe==3.0.3
|
||||
priority==2.0.0
|
||||
python-dotenv==1.2.2
|
||||
quart==0.20.0
|
||||
redis==7.4.0
|
||||
typing-extensions==4.15.0
|
||||
werkzeug==3.1.8
|
||||
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()
|
||||
@@ -0,0 +1,26 @@
|
||||
from dotenv import load_dotenv, find_dotenv
|
||||
import os
|
||||
|
||||
class CreateEnvironmentFile:
|
||||
def __init__(self):
|
||||
self.path = find_dotenv()
|
||||
|
||||
def create_file(self, environment_vars:dict[str], path:str="./.env"):
|
||||
if environment_vars:
|
||||
data = self.convert_dict_to_list(environment_vars)
|
||||
with open(path, 'w') as f:
|
||||
f.writelines(data)
|
||||
|
||||
def convert_dict_to_list(self, data_dict:dict):
|
||||
data = []
|
||||
for k, v in data_dict.items():
|
||||
data.append(f"{k}={v}\n")
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def load_dotenv(cls, environment_vars:dict[str]=None):
|
||||
new_class = cls()
|
||||
if os.path.exists(new_class.path):
|
||||
return load_dotenv(new_class.path)
|
||||
else:
|
||||
return new_class.create_file(environment_vars)
|
||||
@@ -1,16 +1,30 @@
|
||||
import aiofiles.os
|
||||
import aiofiles
|
||||
import os
|
||||
import inspect
|
||||
|
||||
def read_file(path, callback=None):
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r') as f:
|
||||
data = callback(f)
|
||||
return data
|
||||
else:
|
||||
async def read_file(path: str, callback=None):
|
||||
if not await aiofiles.os.path.exists(path):
|
||||
return None
|
||||
|
||||
def save_file(path, data, callback=None, *args, **kwargs):
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
async with aiofiles.open(path, "r") as f:
|
||||
if callback:
|
||||
result = callback(f)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
return await f.read()
|
||||
|
||||
with open(path, 'w') as f:
|
||||
callback(data, f, *args, **kwargs)
|
||||
|
||||
async def save_file(path: str, data, callback=None, *args, **kwargs):
|
||||
dir_path = os.path.dirname(path)
|
||||
if dir_path:
|
||||
await aiofiles.os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
async with aiofiles.open(path, "w") as f:
|
||||
if callback:
|
||||
result = callback(data, f, *args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
else:
|
||||
await f.write(data)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from snakes.TemplateSnake import TemplateSnake
|
||||
from datetime import datetime
|
||||
|
||||
class GameBoard:
|
||||
def __init__(self, game_id:str, width:int, height:int, ruleset:dict, source:str, map:str, snake_class:TemplateSnake):
|
||||
self.id = game_id
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.type = ruleset["name"]
|
||||
self.snake_class = snake_class
|
||||
|
||||
# What will get Stored
|
||||
self.winner_snake_names = None
|
||||
self.now_date = datetime.now()
|
||||
self.turns = []
|
||||
# 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.map = map
|
||||
self.url = self._get_game_url(True if ruleset["version"] == "cli" else False)
|
||||
self.timeout = 500
|
||||
|
||||
# Setter Functions
|
||||
def _set_snakes(self, snakes:list[dict]):
|
||||
self.other_snakes = [ x for x in snakes if x["id"] != self.my_snake["id"] ]
|
||||
|
||||
def _set_my_snake(self, my_snake:dict):
|
||||
self.my_snake = my_snake
|
||||
|
||||
def _set_food(self, food:list[dict]):
|
||||
self.food = food
|
||||
|
||||
def _set_hazards(self, hazards:list[dict]):
|
||||
self.hazards = hazards
|
||||
|
||||
def _set_turn(self, turn:int):
|
||||
self.turn = turn
|
||||
|
||||
# Getter Functions
|
||||
def get_other_snakes(self):
|
||||
return self.other_snakes
|
||||
|
||||
def get_my_snake(self):
|
||||
return self.my_snake
|
||||
|
||||
def get_food(self):
|
||||
return self.food
|
||||
|
||||
def get_hazard(self):
|
||||
return self.hazards
|
||||
|
||||
def get_turn(self):
|
||||
return self.turn
|
||||
|
||||
def get_dimension(self):
|
||||
return {"width": self.width, "height": self.height}
|
||||
|
||||
def get_width(self):
|
||||
return self.width
|
||||
|
||||
def get_height(self):
|
||||
return self.height
|
||||
|
||||
def get_type(self):
|
||||
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):
|
||||
return self.my_snake["head"]
|
||||
|
||||
def get_my_snake_body(self):
|
||||
return self.my_snake["body"]
|
||||
|
||||
def get_my_snake_tail(self):
|
||||
return self.my_snake["body"][-1]
|
||||
|
||||
def get_game_board_as_dict(self):
|
||||
snakes = [self.my_snake]
|
||||
snakes.extend(self.other_snakes)
|
||||
|
||||
return {
|
||||
"height": self.height,
|
||||
"width": self.width,
|
||||
"snakes": snakes,
|
||||
"food": self.food,
|
||||
"hazards": self.hazards,
|
||||
}
|
||||
|
||||
# Game Functions
|
||||
def read_game_data(self, game_data:dict):
|
||||
self._set_food(game_data['board']['food'])
|
||||
self._set_hazards(game_data['board']['hazards'])
|
||||
|
||||
self._set_my_snake(game_data['you'])
|
||||
self._set_snakes(game_data['board']['snakes'])
|
||||
|
||||
self._set_turn(game_data["turn"])
|
||||
self.timeout = int(game_data.get('game', {}).get('timeout', 500))
|
||||
|
||||
async def start_game(self, game_data:dict):
|
||||
self.init_snakes = len(game_data['board']['snakes'])
|
||||
|
||||
def end_game(self, game_data:dict):
|
||||
self._set_winner_snake_name(game_data['board']['snakes'])
|
||||
self.get_type_of_game()
|
||||
|
||||
# Function get Called from Server
|
||||
def snake_neat_make_a_move(self):
|
||||
move = self.snake_class.choose_move(self)
|
||||
|
||||
self.turns.append({
|
||||
"turn": self.turn,
|
||||
"move": move,
|
||||
"game_board": self.get_game_board_as_dict()
|
||||
})
|
||||
|
||||
return move
|
||||
|
||||
# Save functions
|
||||
def _get_game_url(self, local_game:bool):
|
||||
if local_game:
|
||||
return None
|
||||
return f"https://play.battlesnake.com/game/{self.id}"
|
||||
|
||||
def _set_winner_snake_name(self, snakes:list[dict]):
|
||||
if self.my_snake["id"] in [ x["id"] for x in snakes]:
|
||||
self.winner_snake_names = ["me"]
|
||||
else:
|
||||
self.winner_snake_names = [ x["name"] for x in snakes]
|
||||
if len(self.winner_snake_names) == 0:
|
||||
self.winner_snake_names = None
|
||||
|
||||
def get_winner(self):
|
||||
return self.winner_snake_names
|
||||
|
||||
def get_type_of_game(self):
|
||||
if self.init_snakes == 2:
|
||||
return {"name": "duel", "is_ladder": self.is_ladder}
|
||||
|
||||
return {"name": self.type, "is_ladder": self.is_ladder}
|
||||
|
||||
async def save(self, store_class, **kwargs):
|
||||
store = store_class(**kwargs)
|
||||
await store.save(self)
|
||||
del store
|
||||
@@ -1,59 +0,0 @@
|
||||
from server.Files import save_file
|
||||
import os
|
||||
|
||||
class GameStorage:
|
||||
def __init__(self, snake:str, path:str):
|
||||
self.snake_type = snake
|
||||
self.folder = path
|
||||
self.winner_snake_names = None
|
||||
|
||||
def start_new_game(self, game_type:dict, game_board:dict, snake:dict):
|
||||
self.game_type = game_type
|
||||
self.start_position = snake
|
||||
self.game_board = [game_board]
|
||||
self.moves = []
|
||||
|
||||
def add_moves(self, game_board:dict, my_move:str):
|
||||
self.game_board.append(game_board)
|
||||
self.moves.append(my_move)
|
||||
|
||||
def add_end_state(self, game_board:dict, snake_history_state:list[dict], final_turns:int):
|
||||
self.game_board.append(game_board)
|
||||
self.snake_history = snake_history_state
|
||||
self._set_winner_snake_name(game_board['snakes'])
|
||||
self.final_turns = final_turns
|
||||
|
||||
def _set_winner_snake_name(self, snakes:list[dict]):
|
||||
if self.start_position["id"] in [ x["id"] for x in snakes]:
|
||||
self.winner_snake_names = "me"
|
||||
else:
|
||||
self.winner_snake_names = [ x["name"] for x in snakes]
|
||||
|
||||
def _get_type_of_gameboard(self):
|
||||
if len(self.game_board[0]["snakes"]) == 2:
|
||||
return "duel"
|
||||
|
||||
return "standart"
|
||||
|
||||
def save(self, path:str, callback=None, **kwargs):
|
||||
if self.winner_snake_names == "me" and self.final_turns <= 10:
|
||||
return None
|
||||
|
||||
save_file(os.path.join(self.folder, path), {
|
||||
"snake": {
|
||||
"type": self.snake_type,
|
||||
"choices": self.snake_history,
|
||||
},
|
||||
"game": {
|
||||
"type": self._get_type_of_gameboard(),
|
||||
"infos": self.game_type,
|
||||
"snake_start": self.start_position,
|
||||
"final_turns": self.final_turns,
|
||||
"gameboard": self.game_board,
|
||||
"my_moves": self.moves,
|
||||
},
|
||||
"winner": self.winner_snake_names,
|
||||
}, callback=callback, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"<{self.__class__.__name__}> Snake: {self.snake_type}, Folder: {self.folder}, Winner: {self.winner_snake_names}, Old Moves: {self.moves}"
|
||||
@@ -1,113 +1,230 @@
|
||||
from server.Files import read_file, save_file
|
||||
from server.GameStorage import GameStorage
|
||||
from snakes.TemplateSnake import TemplateSnake
|
||||
from server.SnakeBuilder import SnakeBuilder
|
||||
from quart_common.web.logger import build_logger, await_log
|
||||
from quart_common.web.env import env_bool, env_int
|
||||
from server.Files import read_file
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Flask
|
||||
from flask import request
|
||||
import logging, json, os
|
||||
from server.game_state_store import GameStateStoreBuilder
|
||||
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
from server.storage import StorageLoader
|
||||
from server.database import GameplayDatabase
|
||||
|
||||
from server.metrics import (
|
||||
MetricsStoreBuilder,
|
||||
MetricsCollector,
|
||||
)
|
||||
|
||||
import asyncio, signal, logging, json, os, re, time
|
||||
from typing import cast
|
||||
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:
|
||||
default_snake_config = {"apiversion":"1","author":"","color":"#888888","head":"default","tail":"default"}
|
||||
default_snake_config = {
|
||||
'apiversion': '1',
|
||||
'author': '',
|
||||
'color': '#888888',
|
||||
'head': 'default',
|
||||
'tail': 'default',
|
||||
'version': '1.0.0',
|
||||
}
|
||||
|
||||
def __init__(self, data_path:str, snake_type:str, debug:bool=False):
|
||||
def __init__(self, data_path:str, snake_type:str, storage_type:str, debug:bool=False, check_tls_security:bool=False, game_state_backend:str='memory', game_state_redis_url:str='redis://localhost:6379/0', game_state_ttl_sec:int=900, game_state_local_cache:bool=True, metrics_backend:str='memory', metrics_redis_url:str='redis://localhost:6379/0', metrics_ttl_sec:int|None=None, gameplay_db_enabled:bool=True, gameplay_db_path:str|None=None, gameplay_db_busy_timeout_ms:int=5000):
|
||||
self.debug = debug
|
||||
self.snake_type = snake_type
|
||||
self.storage_type = storage_type
|
||||
|
||||
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.store_game_state = False
|
||||
self.running_games:dict[str, GameStorage] = {}
|
||||
self.running_snake:dict[str, TemplateSnake] = {}
|
||||
normalized_backend = (game_state_backend or 'memory').strip().lower()
|
||||
self.game_state_local_cache = (game_state_local_cache and normalized_backend != 'memory')
|
||||
self.game_state_store = GameStateStoreBuilder.build(
|
||||
backend=game_state_backend,
|
||||
redis_url=game_state_redis_url,
|
||||
ttl_seconds=game_state_ttl_sec,
|
||||
)
|
||||
metrics_backend_normalized = (metrics_backend or 'memory').strip().lower()
|
||||
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.app = Flask("Battlesnake")
|
||||
self.game_runtime = GameRuntimeService(
|
||||
game_state_store=self.game_state_store,
|
||||
snake_type=self.snake_type,
|
||||
game_state_local_cache=self.game_state_local_cache,
|
||||
stale_game_timeout_sec=self.stale_game_timeout_sec,
|
||||
)
|
||||
self.dashboard_ws_hub = DashboardWebSocketHub()
|
||||
dashboard_event_origin = f'worker-{os.getpid()}-{int(time.time() * 1000)}'
|
||||
dashboard_events_channel = os.getenv('DASHBOARD_EVENTS_CHANNEL', 'snake:dashboard:events')
|
||||
dashboard_events_enabled = (self.metrics_backend_normalized == 'redis' and env_bool('DASHBOARD_EVENTS_ENABLED', True))
|
||||
|
||||
@self.app.get("/")
|
||||
def on_info():
|
||||
return self._info()
|
||||
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'),
|
||||
),
|
||||
game_state_local_cache=self.game_state_local_cache,
|
||||
metrics_backend=metrics_backend_normalized,
|
||||
game_state_backend=game_state_backend,
|
||||
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,
|
||||
)
|
||||
|
||||
@self.app.post("/start")
|
||||
def on_start():
|
||||
game_state = request.get_json()
|
||||
self._start(game_state)
|
||||
return "ok"
|
||||
self.game_runtime.attach_metrics_collector(self.metrics_collector)
|
||||
self.clear_worker_metrics_on_startup = env_bool('METRICS_CLEAR_WORKERS_ON_STARTUP', True)
|
||||
self.worker_metrics_startup_lock_ttl_sec = env_int('METRICS_STARTUP_CLEANUP_LOCK_TTL_SEC', 300)
|
||||
self.dashboard_running_game_stale_sec = 600
|
||||
self._startup_worker_metrics_cleared = False
|
||||
|
||||
@self.app.post("/move")
|
||||
def on_move():
|
||||
game_state = request.get_json()
|
||||
return self._move(game_state)
|
||||
self.logger = build_logger('Battlesnake', debug_env_var='DEBUG_SERVER')
|
||||
self.snake_version = self._get_snake_version()
|
||||
self.gameplay_database = None
|
||||
if gameplay_db_enabled:
|
||||
db_path = gameplay_db_path or os.path.join(data_path, 'data', 'database', 'gameplay.sqlite3')
|
||||
self.gameplay_database = GameplayDatabase(
|
||||
db_path=db_path,
|
||||
busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||
)
|
||||
|
||||
@self.app.post("/end")
|
||||
def on_end():
|
||||
game_state = request.get_json()
|
||||
self._end(game_state)
|
||||
return "ok"
|
||||
self.gameplay_tracking = GameplayTrackingService(
|
||||
gameplay_database=self.gameplay_database,
|
||||
snake_type=self.snake_type,
|
||||
snake_version=self.snake_version,
|
||||
logger=self.logger,
|
||||
)
|
||||
self.dashboard_query = DashboardQueryService(
|
||||
gameplay_database=self.gameplay_database,
|
||||
ws_hub=self.dashboard_ws_hub,
|
||||
logger=self.logger,
|
||||
dashboard_running_game_stale_sec=self.dashboard_running_game_stale_sec,
|
||||
)
|
||||
self.dashboard_events_service = DashboardEventsService(
|
||||
enabled=dashboard_events_enabled,
|
||||
redis_url=self.metrics_redis_url,
|
||||
channel=dashboard_events_channel,
|
||||
event_origin=dashboard_event_origin,
|
||||
shutdown_event=self.dashboard_ws_hub.shutdown_event,
|
||||
on_notice=self._on_dashboard_games_update_notice,
|
||||
logger=self.logger,
|
||||
)
|
||||
self.dashboard_query.set_publish_notice(self.dashboard_events_service.publish_notice)
|
||||
|
||||
self.app = Quart('Battlesnake', template_folder=os.path.join(data_path, 'server', 'templates'))
|
||||
|
||||
self.app.register_blueprint(create_battlesnake_blueprint(self))
|
||||
self.app.register_blueprint(create_metrics_blueprint(self))
|
||||
self.app.register_blueprint(create_dashboard_blueprint(self))
|
||||
|
||||
@self.app.after_request
|
||||
def identify_server(response):
|
||||
response.headers.set(
|
||||
"server", "battlesnake/github/starter-snake-python"
|
||||
)
|
||||
async def identify_server(response):
|
||||
response.headers.set('server', 'battlesnake/gitea/snake-python')
|
||||
return response
|
||||
|
||||
def run(self, host:str="0.0.0.0", port:str="8000", debug:bool=False):
|
||||
logging.getLogger("werkzeug").setLevel(logging.ERROR)
|
||||
@self.app.before_serving
|
||||
async def clear_startup_worker_metrics_once():
|
||||
if self._startup_worker_metrics_cleared:
|
||||
return
|
||||
self._startup_worker_metrics_cleared = True
|
||||
if self.clear_worker_metrics_on_startup:
|
||||
should_clear = await self.metrics_collector.should_clear_worker_metrics_on_startup(self.worker_metrics_startup_lock_ttl_sec)
|
||||
if 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 {self.snake_type.replace('Snake', '')} Snake")
|
||||
self.app.run(host=host, port=port, debug=debug)
|
||||
@self.app.after_serving
|
||||
async def shutdown_state_storage():
|
||||
await self.dashboard_events_service.stop_listener()
|
||||
await self.game_state_store.close()
|
||||
await self.metrics_collector.close()
|
||||
if self.gameplay_database is not None:
|
||||
await self.gameplay_database.close()
|
||||
|
||||
def _read_json_config_or_create(self):
|
||||
snake_config = read_file(self.config_file, json.load)
|
||||
async def run(self, host:str='0.0.0.0', port:int=8000, debug:bool=False):
|
||||
logging.getLogger('werkzeug').setLevel(logging.ERROR)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
installed_signal_handlers:list[signal.Signals] = []
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def on_shutdown_signal() -> None:
|
||||
self.dashboard_ws_hub.request_shutdown()
|
||||
shutdown_event.set()
|
||||
|
||||
async def shutdown_trigger() -> None:
|
||||
await shutdown_event.wait()
|
||||
|
||||
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:
|
||||
await self.app.run_task(host=host, port=port, debug=debug, shutdown_trigger=shutdown_trigger)
|
||||
finally:
|
||||
self.dashboard_ws_hub.request_shutdown()
|
||||
for shutdown_signal in installed_signal_handlers:
|
||||
try:
|
||||
loop.remove_signal_handler(shutdown_signal)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
async def _read_json_config_or_create(self) -> dict[str, str]:
|
||||
snake_config = cast(dict[str, str]|None, await read_file(self.config_file, json.load))
|
||||
if not snake_config:
|
||||
snake_config = self.default_snake_config
|
||||
save_file(self.config_file, snake_config, callback=json.dump, indent=2, ensure_ascii=False)
|
||||
return snake_config
|
||||
return await self._override_snake_config_with_environment_variables(self.default_snake_config)
|
||||
return await self._override_snake_config_with_environment_variables(snake_config)
|
||||
|
||||
async def _override_snake_config_with_environment_variables(self, config:dict[str, str]) -> dict[str, str]:
|
||||
config['version'] = self.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
|
||||
|
||||
def _get_snake_version(self) -> str:
|
||||
configured_version = SnakeBuilder.get_version(self.snake_type)
|
||||
if configured_version is None:
|
||||
return self.default_snake_config['version']
|
||||
return str(configured_version)
|
||||
|
||||
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):
|
||||
self.store_game_state = True
|
||||
|
||||
# info is called when you create your Battlesnake on play.battlesnake.com
|
||||
# and controls your Battlesnake's appearance
|
||||
# TIP: If you open your Battlesnake URL in a browser you should see this data
|
||||
def _info(self) -> dict:
|
||||
snake_config = self._read_json_config_or_create()
|
||||
print("INFO Snake:", snake_config)
|
||||
return snake_config
|
||||
def _cleanup_database(self):
|
||||
storage = StorageLoader.build(self.storage_type)
|
||||
return storage.cleanup()
|
||||
|
||||
# start is called when your Battlesnake begins a game
|
||||
def _start(self, game_state:dict):
|
||||
if self.store_game_state:
|
||||
self.running_games[game_state["game"]["id"]] = GameStorage(self.snake.__class__.__name__, path=os.path.join(self.data_path, 'data', 'history'))
|
||||
self.running_games[game_state["game"]["id"]].start_new_game(game_state["game"], game_state["board"], game_state["you"])
|
||||
|
||||
self.running_snake[game_state["game"]["id"]] = SnakeBuilder.build(self.snake_type)
|
||||
print("GAME START:", game_state["game"])
|
||||
|
||||
# move is called when your Battlesnake game is running game
|
||||
def _move(self, game_state:dict) -> dict:
|
||||
next_move = self.running_snake[game_state["game"]["id"]].choose_move(game_state)
|
||||
|
||||
if self.store_game_state:
|
||||
self.running_games[game_state["game"]["id"]].add_moves(game_state["board"], next_move)
|
||||
if self.debug:
|
||||
print(self.running_games[game_state["game"]["id"]])
|
||||
|
||||
print("MOVE:", f"{next_move:5},", "Me:", {"head": game_state["you"]["head"], "length": game_state["you"]["length"]})
|
||||
return {"move": next_move}
|
||||
|
||||
# end is called when your Battlesnake finishes a game
|
||||
def _end(self, game_state:dict):
|
||||
if self.store_game_state:
|
||||
snake = self.running_snake[game_state["game"]["id"]]
|
||||
|
||||
self.running_games[game_state["game"]["id"]].add_end_state(game_state["board"], snake.get_history(), game_state["turn"])
|
||||
self.running_games[game_state["game"]["id"]].save(
|
||||
f"{snake.__class__.__name__}_{datetime.now().strftime('%d.%m.%Y_%H%M%S')}_{game_state['game']['id']}.json",
|
||||
callback=json.dump, indent=2, ensure_ascii=False
|
||||
)
|
||||
del self.running_games[game_state["game"]["id"]]
|
||||
|
||||
print("GAME OVER:\n- Winner is", [ x["name"] for x in game_state["board"]['snakes']])
|
||||
del self.running_snake[game_state["game"]["id"]]
|
||||
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,83 @@
|
||||
from typing import TYPE_CHECKING, cast
|
||||
import json, time, os
|
||||
|
||||
from quart import Blueprint, request, jsonify
|
||||
|
||||
from quart_common.web.logger import await_log
|
||||
from server.storage import StorageLoader
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.Server import Server
|
||||
|
||||
def create_battlesnake_blueprint(server:'Server') -> Blueprint:
|
||||
blueprint = Blueprint('battlesnake', __name__)
|
||||
|
||||
@blueprint.get('/')
|
||||
async def on_info():
|
||||
server.metrics_collector.record_http_request('info')
|
||||
snake_config = await server._read_json_config_or_create()
|
||||
await await_log(server.logger.info(f'INFO Snake: {snake_config}'))
|
||||
return snake_config
|
||||
|
||||
@blueprint.post('/start')
|
||||
async def on_start():
|
||||
server.metrics_collector.record_http_request('start')
|
||||
await server.game_runtime.prune_stale_games()
|
||||
game_state = await request.get_json()
|
||||
await server.game_runtime.create_game_board(game_state)
|
||||
await server.gameplay_tracking.record_gameplay_start(game_state)
|
||||
await await_log(server.logger.info(f'GAME START: {game_state['game']}'))
|
||||
return 'ok'
|
||||
|
||||
@blueprint.post('/move')
|
||||
async def on_move():
|
||||
server.metrics_collector.record_http_request('move')
|
||||
game_state = await request.get_json()
|
||||
move_started = time.perf_counter()
|
||||
game_board = cast(GameBoard, await server.game_runtime.get_game_board(game_state))
|
||||
next_move = game_board.snake_neat_make_a_move()
|
||||
await server.game_runtime.persist_game_board(game_state['game']['id'], game_board)
|
||||
await server.gameplay_tracking.record_gameplay_turn(game_state, next_move, game_board)
|
||||
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')
|
||||
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,119 @@
|
||||
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.html',
|
||||
initial_game_id=initial_game_id,
|
||||
initial_summary=initial_summary,
|
||||
initial_games=initial_games,
|
||||
)
|
||||
|
||||
@blueprint.get('/dashboard/customizations/<path:asset_path>')
|
||||
async def dashboard_customizations_asset(asset_path:str):
|
||||
customization_root = os.path.join(
|
||||
server.data_path,
|
||||
'server',
|
||||
'static',
|
||||
'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,30 @@
|
||||
from quart import Blueprint, jsonify
|
||||
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,
|
||||
)
|
||||
return jsonify(snapshot)
|
||||
|
||||
@blueprint.get('/metrics/prometheus')
|
||||
async def metrics_prometheus():
|
||||
snapshot = await server.metrics_collector.build_snapshot(
|
||||
server.game_runtime.game_last_seen_unix,
|
||||
server.game_runtime.game_move_counts,
|
||||
)
|
||||
return (
|
||||
server.metrics_collector.build_prometheus_metrics(snapshot),
|
||||
200,
|
||||
{'Content-Type': 'text/plain; version=0.0.4; charset=utf-8'},
|
||||
)
|
||||
|
||||
return blueprint
|
||||
@@ -0,0 +1,69 @@
|
||||
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)
|
||||
backend_default = os.environ.get('BACKEND', 'memory')
|
||||
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
game_state_backend = os.environ.get('GAME_STATE_BACKEND', backend_default)
|
||||
game_state_redis_url = os.environ.get('GAME_STATE_REDIS_URL', redis_url)
|
||||
game_state_ttl_sec = env_int('GAME_STATE_TTL_SEC', 900)
|
||||
|
||||
metrics_backend = os.environ.get('METRICS_BACKEND', None)
|
||||
if metrics_backend is None:
|
||||
metrics_backend = os.environ.get('BACKEND', None)
|
||||
if metrics_backend is None:
|
||||
metrics_backend = ('redis' if game_state_backend.strip().lower() == 'redis' else 'memory')
|
||||
|
||||
metrics_redis_url = os.environ.get('METRICS_REDIS_URL', redis_url)
|
||||
metrics_ttl_sec_raw = os.environ.get('METRICS_TTL_SEC', None)
|
||||
if metrics_ttl_sec_raw is None:
|
||||
metrics_ttl_sec = (game_state_ttl_sec if metrics_backend.strip().lower() == 'redis' else None)
|
||||
else:
|
||||
metrics_ttl_sec = env_int('METRICS_TTL_SEC', game_state_ttl_sec)
|
||||
|
||||
gameplay_db_enabled = env_bool('GAMEPLAY_DB_ENABLED', True)
|
||||
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)
|
||||
|
||||
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,
|
||||
game_state_backend=game_state_backend,
|
||||
game_state_redis_url=game_state_redis_url,
|
||||
game_state_ttl_sec=game_state_ttl_sec,
|
||||
game_state_local_cache=env_bool('GAME_STATE_LOCAL_CACHE', default=True),
|
||||
metrics_backend=metrics_backend,
|
||||
metrics_redis_url=metrics_redis_url,
|
||||
metrics_ttl_sec=metrics_ttl_sec,
|
||||
gameplay_db_enabled=gameplay_db_enabled,
|
||||
gameplay_db_path=gameplay_db_path,
|
||||
gameplay_db_busy_timeout_ms=gameplay_db_busy_timeout_ms,
|
||||
)
|
||||
|
||||
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'),
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
from datetime import datetime, timezone
|
||||
import asyncio, sqlite3, json
|
||||
from pathlib import Path
|
||||
|
||||
class GameplayDatabase:
|
||||
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._initialize_database()
|
||||
|
||||
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
|
||||
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 _initialize_database(self) -> None:
|
||||
Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
|
||||
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_names_json 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
|
||||
);
|
||||
|
||||
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);
|
||||
""")
|
||||
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")
|
||||
connection.execute("PRAGMA optimize")
|
||||
|
||||
def _ensure_column_exists(self, connection:sqlite3.Connection, table_name:str, column_name:str, column_type:str) -> None:
|
||||
existing = connection.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
if any(row["name"] == column_name for row in existing):
|
||||
return
|
||||
|
||||
connection.execute(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}")
|
||||
|
||||
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):
|
||||
if payload is None or payload == "":
|
||||
return None
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
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
|
||||
|
||||
delta_x = new_head[0] - old_head[0]
|
||||
delta_y = new_head[1] - old_head[1]
|
||||
if delta_x == 1 and delta_y == 0:
|
||||
return "right"
|
||||
if delta_x == -1 and delta_y == 0:
|
||||
return "left"
|
||||
if delta_x == 0 and delta_y == 1:
|
||||
return "up"
|
||||
if delta_x == 0 and delta_y == -1:
|
||||
return "down"
|
||||
return None
|
||||
|
||||
def _derive_game_type(self, board:dict, ruleset:dict) -> str:
|
||||
initial_snake_count = len(board.get("snakes", []))
|
||||
if initial_snake_count == 2:
|
||||
return "duel"
|
||||
return ruleset.get("name") or "standard"
|
||||
|
||||
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 incremental_vacuum(200)")
|
||||
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_names = [snake.get("name") for snake in snakes if snake.get("name")]
|
||||
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_names_json = ?,
|
||||
winner_you = ?,
|
||||
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
|
||||
status = 'finished'
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(
|
||||
self._utc_now(),
|
||||
self._to_json(winner_names),
|
||||
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
|
||||
age_seconds = (now_utc - started_at).total_seconds()
|
||||
if age_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_turn_row = connection.execute("""
|
||||
SELECT MAX(turn) AS latest_turn
|
||||
FROM snake_turns
|
||||
WHERE game_id = ?
|
||||
""",
|
||||
(game_id,),
|
||||
).fetchone()
|
||||
latest_turn = (
|
||||
latest_turn_row["latest_turn"]
|
||||
if latest_turn_row is not None
|
||||
else None
|
||||
)
|
||||
if latest_turn is not None:
|
||||
final_turn = int(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 = [snake["snake_id"] for snake in snake_rows if snake["snake_id"]]
|
||||
survivor_names = [snake["snake_name"] for snake in snake_rows if snake["snake_name"]]
|
||||
winner_you = bool(
|
||||
your_snake_id
|
||||
and your_snake_id in survivor_ids
|
||||
and len(survivor_ids) == 1
|
||||
)
|
||||
|
||||
update_result = connection.execute("""
|
||||
UPDATE games
|
||||
SET ended_at = ?,
|
||||
winner_names_json = ?,
|
||||
winner_you = ?,
|
||||
final_turn = CASE WHEN ? > final_turn THEN ? ELSE final_turn END,
|
||||
status = 'finished'
|
||||
WHERE game_id = ? AND status = 'running'
|
||||
""",
|
||||
(
|
||||
self._utc_now(),
|
||||
self._to_json(survivor_names),
|
||||
1 if winner_you else 0,
|
||||
final_turn,
|
||||
final_turn,
|
||||
game_id,
|
||||
),
|
||||
)
|
||||
if update_result.rowcount > 0:
|
||||
finalized += 1
|
||||
|
||||
return finalized
|
||||
|
||||
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_names_json, 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_names": self._from_json(row["winner_names_json"]) or [],
|
||||
"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_names_json, 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:
|
||||
turn = int(row["turn"])
|
||||
snakes_by_turn.setdefault(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"],
|
||||
})
|
||||
|
||||
replay_turns = []
|
||||
for row in turn_rows:
|
||||
turn_number = int(row["turn"])
|
||||
replay_turns.append({
|
||||
"turn": turn_number,
|
||||
"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(turn_number, []),
|
||||
})
|
||||
|
||||
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_names": self._from_json(game_row["winner_names_json"]) or [],
|
||||
"winner_you": bool(game_row["winner_you"]),
|
||||
"final_turn": int(game_row["final_turn"] or 0),
|
||||
"status": game_row["status"],
|
||||
},
|
||||
"turns": replay_turns,
|
||||
}
|
||||
|
||||
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 @@
|
||||
from .GameplayDatabase import GameplayDatabase
|
||||
@@ -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,20 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
class MemoryGameBoardStore:
|
||||
def __init__(self, **kwargs):
|
||||
self._state:dict[str, object] = {}
|
||||
|
||||
async def save(self, game_id:str, game_board:'GameBoard') -> None:
|
||||
self._state[game_id] = game_board
|
||||
|
||||
async def load(self, game_id:str):
|
||||
return self._state.get(game_id)
|
||||
|
||||
async def delete(self, game_id:str) -> None:
|
||||
self._state.pop(game_id, None)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@@ -0,0 +1,61 @@
|
||||
from typing import TYPE_CHECKING
|
||||
import inspect, pickle
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
class RedisGameBoardStore:
|
||||
def __init__(self, redis_url:str="redis://localhost:6379/0", key_prefix:str="snake:gameboard", ttl_seconds:int=900, **kwargs):
|
||||
self.redis_url = redis_url
|
||||
self.key_prefix = key_prefix
|
||||
self.ttl_seconds = max(60, int(ttl_seconds))
|
||||
self._redis = None
|
||||
|
||||
async def _get_redis(self):
|
||||
if self._redis is not None:
|
||||
return self._redis
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis # type: ignore[import-not-found]
|
||||
except ImportError as error: # pragma: no cover
|
||||
raise RuntimeError("Redis backend selected but 'redis' package with asyncio support is not installed") from error
|
||||
|
||||
self._redis = aioredis.from_url(self.redis_url)
|
||||
return self._redis
|
||||
|
||||
def _key(self, game_id:str) -> str:
|
||||
return f"{self.key_prefix}:{game_id}"
|
||||
|
||||
async def save(self, game_id:str, game_board:'GameBoard') -> None:
|
||||
redis = await self._get_redis()
|
||||
payload = pickle.dumps(game_board, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
await redis.set(self._key(game_id), payload, ex=self.ttl_seconds)
|
||||
|
||||
async def load(self, game_id:str):
|
||||
redis = await self._get_redis()
|
||||
payload = await redis.get(self._key(game_id))
|
||||
if payload is None:
|
||||
return None
|
||||
return pickle.loads(payload)
|
||||
|
||||
async def delete(self, game_id:str) -> None:
|
||||
redis = await self._get_redis()
|
||||
await redis.delete(self._key(game_id))
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._redis is None:
|
||||
return
|
||||
|
||||
aclose_method = getattr(self._redis, "aclose", None)
|
||||
if callable(aclose_method):
|
||||
maybe_result = aclose_method()
|
||||
if inspect.isawaitable(maybe_result):
|
||||
await maybe_result
|
||||
else:
|
||||
close_method = getattr(self._redis, "close", None)
|
||||
if callable(close_method):
|
||||
close_result = close_method()
|
||||
if inspect.isawaitable(close_result):
|
||||
await close_result
|
||||
|
||||
self._redis = None
|
||||
@@ -0,0 +1,10 @@
|
||||
from .MemoryGameBoardStore import MemoryGameBoardStore
|
||||
from .RedisGameBoardStore import RedisGameBoardStore
|
||||
|
||||
class GameStateStoreBuilder:
|
||||
@classmethod
|
||||
def build(self, backend:str="memory", **kwargs) -> MemoryGameBoardStore|RedisGameBoardStore:
|
||||
selected = (backend or "memory").strip().lower()
|
||||
if selected == "redis":
|
||||
return RedisGameBoardStore(**kwargs)
|
||||
return MemoryGameBoardStore(**kwargs)
|
||||
@@ -0,0 +1,269 @@
|
||||
from server.metrics.backends.Template import StoreTemplate
|
||||
|
||||
import time
|
||||
|
||||
class MetricsCollector:
|
||||
def __init__(self, metrics_manager:StoreTemplate, game_state_local_cache:bool, metrics_backend:str, game_state_backend:str, stale_game_timeout_sec:int, game_last_seen_unix:dict, game_move_counts:dict):
|
||||
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._game_state_backend_is_redis = game_state_backend.strip().lower() == 'redis'
|
||||
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,
|
||||
'game_state_local_cache_enabled': bool(game_state_local_cache),
|
||||
'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:
|
||||
if self._game_state_backend_is_redis:
|
||||
return
|
||||
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
|
||||
)
|
||||
|
||||
if self._game_state_backend_is_redis:
|
||||
# Redis auto-expires stale keys via TTL, so stale games are already gone from the
|
||||
# server's perspective. We exclude them from all metrics so we only report games
|
||||
# that are actually still alive in Redis.
|
||||
report_active_games = len(game_last_seen_unix) - stale_candidates
|
||||
report_stale_candidates = 0
|
||||
# Only include non-stale timestamps when calculating the oldest active game age,
|
||||
# so a game that Redis already deleted doesn't inflate the age metric.
|
||||
active_last_seen = [
|
||||
last_seen
|
||||
for last_seen in game_last_seen_unix.values()
|
||||
if now - last_seen < self._stale_game_timeout_sec
|
||||
]
|
||||
else:
|
||||
report_active_games = len(game_last_seen_unix)
|
||||
report_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,137 @@
|
||||
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,
|
||||
"game_state_local_cache_enabled": False,
|
||||
"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)))
|
||||
merged["game_state_local_cache_enabled"] = merged["game_state_local_cache_enabled"] or bool(worker.get("game_state_local_cache_enabled", False))
|
||||
|
||||
for endpoint in merged["http_requests_by_endpoint"]:
|
||||
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,108 @@
|
||||
from typing import Protocol, cast
|
||||
import time
|
||||
|
||||
from server.metrics import MetricsCollector
|
||||
from server.GameBoard import GameBoard
|
||||
|
||||
from snakes import SnakeBuilder
|
||||
|
||||
class GameStateStoreLike(Protocol):
|
||||
async def save(self, game_id: str, game_board: GameBoard) -> None: ...
|
||||
|
||||
async def load(self, game_id: str) -> object | None: ...
|
||||
|
||||
async def delete(self, game_id: str) -> None: ...
|
||||
|
||||
class GameRuntimeService:
|
||||
def __init__(self, game_state_store:GameStateStoreLike, snake_type:str, game_state_local_cache:bool, stale_game_timeout_sec:int):
|
||||
self.game_state_store = game_state_store
|
||||
self.snake_type = snake_type
|
||||
self.game_state_local_cache = game_state_local_cache
|
||||
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)
|
||||
|
||||
if self.game_state_local_cache:
|
||||
self.running_games[game_id] = new_game_board
|
||||
|
||||
await self.game_state_store.save(game_id, new_game_board)
|
||||
self.game_move_counts[game_id] = 0
|
||||
self.game_last_seen_unix[game_id] = int(time.time())
|
||||
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 persist_game_board(self, game_id:str, game_board:GameBoard) -> None:
|
||||
if self.game_state_local_cache:
|
||||
self.running_games[game_id] = game_board
|
||||
await self.game_state_store.save(game_id, game_board)
|
||||
|
||||
async def delete_game_board(self, game_state:dict) -> 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)
|
||||
await self.game_state_store.delete(game_id)
|
||||
|
||||
async def get_game_board(self, game_state:dict, end:bool=False) -> GameBoard:
|
||||
game_id = game_state['game']['id']
|
||||
game_board:GameBoard
|
||||
if self.game_state_local_cache and game_id in self.running_games:
|
||||
game_board = self.running_games[game_id]
|
||||
else:
|
||||
persisted_board = await self.game_state_store.load(game_id)
|
||||
if persisted_board is not None:
|
||||
game_board = cast(GameBoard, persisted_board)
|
||||
if self.game_state_local_cache:
|
||||
self.running_games[game_id] = game_board
|
||||
else:
|
||||
game_board = await self.create_game_board(game_state)
|
||||
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)
|
||||
await self.persist_game_board(game_id, game_board)
|
||||
|
||||
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, snake_type:str, snake_version:str, logger:logging):
|
||||
self.gameplay_database = gameplay_database
|
||||
self.snake_type = snake_type
|
||||
self.snake_version = snake_version
|
||||
self.logger = logger
|
||||
|
||||
async def record_gameplay_start(self, game_state:dict) -> None:
|
||||
if self.gameplay_database is None:
|
||||
return
|
||||
try:
|
||||
await self.gameplay_database.record_game_start(
|
||||
game_state,
|
||||
snake_type=self.snake_type,
|
||||
snake_version=self.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
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="Layer_3" data-name="Layer 3"><path d="M46.77,21.35a28,28,0,1,0,28,28A28,28,0,0,0,46.77,21.35Zm0,50.86A22.83,22.83,0,1,1,69.6,49.38,22.83,22.83,0,0,1,46.77,72.21Z"/><path d="M50,0H0V100H50A50,50,0,0,0,50,0ZM73.4,15.46l1.18-1.41a1.42,1.42,0,0,1,2.17,1.82l-1.19,1.41a1.41,1.41,0,0,1-2.16-1.82ZM45.45,4.4a1.42,1.42,0,1,1,2.83,0V6.23a1.42,1.42,0,1,1-2.83,0ZM17.11,13.73a1.4,1.4,0,0,1,2,.18l1.19,1.4a1.42,1.42,0,0,1-2.17,1.82l-1.18-1.41A1.41,1.41,0,0,1,17.11,13.73ZM4.17,43.05l-1.81-.32A1.41,1.41,0,0,1,2.85,40l1.81.32a1.41,1.41,0,1,1-.49,2.78Zm6,28.82-1.59.91a1.41,1.41,0,1,1-1.41-2.44l1.59-.92a1.41,1.41,0,0,1,1.41,2.45ZM33.26,90.1l-.63,1.72a1.41,1.41,0,1,1-2.65-1l.63-1.73a1.41,1.41,0,0,1,2.65,1Zm29.2,2.65a1.42,1.42,0,0,1-1.81-.85L60,90.18a1.42,1.42,0,0,1,2.66-1l.63,1.73A1.42,1.42,0,0,1,62.46,92.75Zm18.38-23.7a1.41,1.41,0,0,1-1.93.52l-6.47-3.73-.13-.12a30.77,30.77,0,0,1-5.08,6s.11,0,.15.08l4.8,5.72A1.41,1.41,0,1,1,70,79.34l-4.8-5.72s0-.11,0-.16a30.31,30.31,0,0,1-6.8,4l.06.08,2.55,7a1.41,1.41,0,1,1-2.65,1l-2.56-7s0-.07,0-.1A30.35,30.35,0,0,1,48,79.67a.45.45,0,0,1,0,.11v7.47a1.42,1.42,0,1,1-2.83,0V79.78s0-.08,0-.13a29.82,29.82,0,0,1-7.71-1.37s0,.07,0,.11L35,85.4a1.41,1.41,0,0,1-2.66-1l2.56-7s0-.06.06-.1a30.56,30.56,0,0,1-6.77-4c0,.06,0,.12-.05.17l-4.8,5.72a1.41,1.41,0,1,1-2.16-1.81L26,71.66c0-.06.12,0,.18-.1a30.48,30.48,0,0,1-5-6.06.93.93,0,0,1-.15.13l-6.47,3.74a1.41,1.41,0,0,1-1.41-2.45l6.46-3.73a1.72,1.72,0,0,1,.21-.07,30.6,30.6,0,0,1-2.64-7.38,1.31,1.31,0,0,1-.21.08l-7.35,1.3A1.42,1.42,0,0,1,7.9,56a1.41,1.41,0,0,1,1.15-1.63L16.4,53a.82.82,0,0,1,.27,0,29.76,29.76,0,0,1-.25-3.67,30.08,30.08,0,0,1,.32-4.17,1.5,1.5,0,0,1-.3,0L9.09,43.92a1.42,1.42,0,1,1,.49-2.79l7.35,1.3a1.57,1.57,0,0,1,.3.12A30.09,30.09,0,0,1,20,35.19a1.24,1.24,0,0,1-.31-.1l-6.47-3.73a1.41,1.41,0,1,1,1.41-2.45l6.47,3.73a1.59,1.59,0,0,1,.27.24,30.26,30.26,0,0,1,5.16-6c-.12-.08-.27-.08-.37-.2L21.32,21a1.41,1.41,0,0,1,2.17-1.81l4.8,5.72c.1.12.07.27.13.41a30,30,0,0,1,6.86-4,1.57,1.57,0,0,1-.19-.33l-2.56-7a1.41,1.41,0,1,1,2.65-1l2.56,7a1.67,1.67,0,0,1,.06.38,30.28,30.28,0,0,1,7.73-1.3,1.59,1.59,0,0,1-.08-.39V11.23a1.42,1.42,0,1,1,2.83,0V18.7a1.35,1.35,0,0,1-.08.4,30.47,30.47,0,0,1,7.73,1.35,1.49,1.49,0,0,1,0-.36l2.55-7a1.41,1.41,0,0,1,2.66,1l-2.56,7a1.23,1.23,0,0,1-.19.31,30.1,30.1,0,0,1,6.83,4c.06-.12,0-.26.12-.37l4.8-5.72a1.41,1.41,0,0,1,2.17,1.81l-4.81,5.72c-.09.11-.23.11-.35.19a29.92,29.92,0,0,1,5.11,6.05,1.17,1.17,0,0,1,.24-.21L79,29.11a1.42,1.42,0,0,1,1.42,2.45L74,35.29a1.76,1.76,0,0,1-.3.1,30.17,30.17,0,0,1,2.7,7.36,1,1,0,0,1,.24-.09L84,41.36a1.42,1.42,0,0,1,1.64,1.15,1.41,1.41,0,0,1-1.15,1.63l-7.35,1.3a.82.82,0,0,1-.27,0,30.35,30.35,0,0,1,.29,4,29.31,29.31,0,0,1-.28,3.89.81.81,0,0,1,.22,0l7.35,1.29a1.42,1.42,0,1,1-.49,2.79l-7.35-1.3a.64.64,0,0,1-.19-.08,30.07,30.07,0,0,1-2.69,7.37,1,1,0,0,1,.16,0l6.47,3.73A1.42,1.42,0,0,1,80.84,69.05Zm5.91,3.42a1.42,1.42,0,0,1-1.93.52l-1.59-.92a1.41,1.41,0,0,1,1.41-2.45l1.6.92A1.41,1.41,0,0,1,86.75,72.47ZM91.18,43l-1.81.32a1.42,1.42,0,0,1-1.64-1.15,1.41,1.41,0,0,1,1.15-1.63l1.81-.32a1.39,1.39,0,0,1,1.63,1.14A1.41,1.41,0,0,1,91.18,43Z"/><path d="M39.76,49.38c0,10.79,7,19.54,7,19.54s7-8.75,7-19.54-7-19.54-7-19.54S39.76,38.59,39.76,49.38Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M94.56,68.55l-25.9,7.24S63.1,60.28,62,60.5s-17.93,8.71-17.93,8.71L32.22,60s-1.76-12.08-1.1-12.29,19.39,9.36,20.05,8.7,9.59-17.49,9.59-17.49l17,9L84.9,27.5l7.54,5.78,2.59-5c3.54-6.6,3.75-12.47,4.25-19.95C99.46,5.56,98.1,3.63,96,3L92.6,1.9C89.15.85,85.14,3,85.34,8.4c-.87.55-21.51,8.07-27.16,9.47a15.73,15.73,0,0,0-15-10.52c-8.75,0-16.78,7.2-15.74,16.14C21.27,22.1,4.89,6,0,0V100H31.34l7.25-5.11,12.73-.66,4,2.86H65.8l31.87-18.3a4.3,4.3,0,0,0,2.16-3.73C99.83,72.36,98.73,67,94.56,68.55ZM92.7,5.77c1.27,0,2.3,2.5,2.3,5.6S94,17,92.7,17s-2.31-2.5-2.31-5.59S91.42,5.77,92.7,5.77ZM43.2,14.55a8.19,8.19,0,0,1,8.34,8,8.35,8.35,0,0,1-16.69,0A8.19,8.19,0,0,1,43.2,14.55Z"/></svg>
|
||||
|
After Width: | Height: | Size: 748 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.antelope-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="antelope-cls-1" cx="47.25" cy="62.48" r="9" transform="translate(-9.4 8.38) rotate(-9.22)"/></g><g id="art"><path d="M33.27,23.17l.16.35C33.37,23.41,33.33,23.28,33.27,23.17Z"/><path d="M5.09,35.34A32.44,32.44,0,0,1,0,25.84V0C8.62,0,25.63,6.26,33.15,22.87l.12.3c.06.11.1.24.16.35,1.32,3.21,4,11.14,3.12,17.29a23.7,23.7,0,0,0,1.27-10.16l1.44,0C38.4,18.36,32.46,7.74,22.33,0A23,23,0,0,1,34.56,3.51a5.2,5.2,0,0,0-.92,2.39A8.54,8.54,0,0,1,35.24,4a28.22,28.22,0,0,1,6.31,5.93c-1.16,1.25-2.27,2.79-2.36,4.21A9.5,9.5,0,0,1,42.37,11a44.09,44.09,0,0,1,4.7,8.25c-1.62,1.13-4.07,3.13-4.45,5.13A10.18,10.18,0,0,1,47.82,21a60.88,60.88,0,0,1,2.41,7.45,15.68,15.68,0,0,0-5.31,2.38,4.51,4.51,0,0,0-1.12,1.25A8.82,8.82,0,0,1,46.16,31a11.15,11.15,0,0,1,3.54-.45,43.68,43.68,0,0,0,3.39-19.85A22.87,22.87,0,0,1,57.92,20a4.65,4.65,0,0,0-2.07.23,8.48,8.48,0,0,1,2.27.55,25.84,25.84,0,0,1,.36,8.08,7.28,7.28,0,0,0-4.35-.2,9.32,9.32,0,0,1,4.18,1.54A37.17,37.17,0,0,1,56.94,36C66.52,43.94,72,58.43,78.7,63.86c4.47,3.66,14.21,10.61,13.77,16.65S77.25,99,74.69,99.46c-5.16.89-12.4-4-12.29-11.38.32,5.71-4.28,6.6-7.25,5.83s-47-5.5-33.36-35.35c-4.5,2.52-10.21,13.51-7,21.3A113.08,113.08,0,0,0,0,100V38.12a14,14,0,0,1,3.67-2A25.48,25.48,0,0,0,9.9,40.08,24.48,24.48,0,0,1,5.09,35.34Zm73.2,55.39a2.59,2.59,0,0,0,4.3,1.06,50,50,0,0,0,7.35-9.24,5,5,0,0,0-.89-6.48c-2-1.56-8.51-.4-10.12.31C76.21,77.57,76.51,85,78.29,90.73Zm-31-19.25a9,9,0,1,0-9-9A9,9,0,0,0,47.25,71.48ZM23.66,31.68c.62,1.63,1.09,3,1.53,4l-4.45-2.47s-2.3,6.05-1,10.65a7.46,7.46,0,0,0,.61,1.55,8.88,8.88,0,0,1,.05-1.61,40.55,40.55,0,0,1,1.92-7.35A16.08,16.08,0,0,0,28,41.19a63.64,63.64,0,0,0-1.88-9.81C22.51,18.31,15.4,12.83,7.66,9.14A48.48,48.48,0,0,1,23.66,31.68Z"/><path d="M43.58,56.23a2.92,2.92,0,1,1-2.92,2.91A2.91,2.91,0,0,1,43.58,56.23Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M20.21,13.63a47.27,47.27,0,0,0,.3,73A49.42,49.42,0,0,0,25,82.48L20.44,82l0,0-.78-.08L21,78.78l1.59.18h0l4.79.52a47.48,47.48,0,0,0,5.24-9.24l-6.18.34,1.21-3.31,6.25-.35a51.37,51.37,0,0,0,2-7.74L30.56,61l.29-3.52,5.6-1.87a53.59,53.59,0,0,0,.3-5.56c0-1.43-.07-2.84-.18-4.24l-4.78,3.9-1-3.39L36.12,42a50.56,50.56,0,0,0-2.47-9.65l-5,4.67-1.22-3.31,4.87-4.58a47.15,47.15,0,0,0-6-9.89l-3.76,5.67-.29.42-2.15-3.22,4-5.43A32.09,32.09,0,0,0,20.21,13.63Z"/><path d="M73.54,84l-4.22,5.51-1.73-3.07,3.91-5.1a50.7,50.7,0,0,1-5.56-10l-4.87,4.56-1.2-3.32,4.82-4.52a54.17,54.17,0,0,1-2.28-9.17L56.1,62.26l-.29-3.52L62,55.5c-.17-1.8-.27-3.62-.27-5.47s.1-3.68.27-5.49l-6.53.81,1-3.39,6-.74a54.75,54.75,0,0,1,2.54-9.93l-6.05.32,1.21-3.31L66.25,28a50.87,50.87,0,0,1,6-10.29l-5.41-1,1.93-3,5.82,1a46.05,46.05,0,0,1,3.9-3.88A48,48,0,0,0,51.13,2.41h0a48,48,0,0,0-27.35,8.51,46.05,46.05,0,0,1,3.9,3.88l5.82-1,1.93,3-5.42,1A50.44,50.44,0,0,1,36,28l6.16.33,1.21,3.31-6-.32a54.75,54.75,0,0,1,2.54,9.93l6,.74,1,3.39-6.53-.81c.17,1.81.27,3.63.27,5.49s-.1,3.67-.27,5.47l6.14,3.24-.28,3.52-6.31-3.33a54.17,54.17,0,0,1-2.28,9.17l4.82,4.52-1.2,3.32-4.87-4.56a51.22,51.22,0,0,1-5.56,10l3.91,5.1L33,89.53,28.72,84a46.3,46.3,0,0,1-4.9,5.09A48,48,0,0,0,51.13,97.6h0a48,48,0,0,0,27.32-8.49A46.39,46.39,0,0,1,73.54,84Z"/><path d="M82.06,13.63a32.09,32.09,0,0,0-4,3l4,5.43L79.92,25.3l-.28-.42-3.76-5.67a47.15,47.15,0,0,0-5.95,9.89l4.86,4.58L73.58,37l-5-4.67A50.56,50.56,0,0,0,66.15,42l5.3,4.33-1,3.39-4.77-3.9c-.12,1.4-.18,2.81-.18,4.24a53.59,53.59,0,0,0,.3,5.56l5.6,1.87L71.71,61l-5.39-1.8a50.2,50.2,0,0,0,2,7.74l6.25.35,1.2,3.31-6.17-.34a47.44,47.44,0,0,0,5.23,9.24l4.8-.52,1.59-.18,1.35,3.12-.77.08,0,0-4.6.48a49.42,49.42,0,0,0,4.53,4.14,47.28,47.28,0,0,0,.3-73Z"/><path d="M1.13,50a50.15,50.15,0,0,1,50-50H0V100H51.13A50.15,50.15,0,0,1,1.13,50Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.puf-spec-head-1{fill:#0f0}.puf-spec-head-2{fill:#fff}.puf-spec-head-3{fill:#e84600}</style></defs><path d="M0,100s9.24-.1,14.82-.1c27.61,0,50-22.39,50-50S42.44-.1,14.82-.1C9.42-.1,0,0,0,0V100Z"/><path class="puf-spec-head-3" d="M56.95,64.82c.9,.37,1.8,.69,2.74,.92,.64,.16,1.27,.27,1.88,.33,2.22,.24,5.13,.29,8.22,.35,4.63,.09,9.88,.19,12.94,.94,.21,.05,.4,.1,.59,.16,2.66,.83,4.74,2.6,7.3,4.84-12.49,9.52-41.72,27-62.31,1.58-1.66-2.05,17.21-5.49,28.64-9.13Z"/><path class="puf-spec-head-3" d="M42.54,10.95c-2.66-1.86,53.36,13.85,55.5,58.12-6.75-2.8-10.57-6.13-16-6.46-5.43-.33-16.34,3.01-22.05,3.79-7.96,1.08-17.46-7.25-28.99-.52,4.16-8.11,9.5-12.49,19.94-7.21,4.77-2.96,20.08-27.79-8.39-47.71Z"/><path class="puf-spec-head-1" d="M32.9,19.38c-.14-.31-.26-.63-.36-.97l.36,.97Z"/><path class="puf-spec-head-2" d="M32.7,31.25c0,1.47,1.19,2.66,2.66,2.66s2.66-1.19,2.66-2.66-1.19-2.66-2.66-2.66-2.66,1.19-2.66,2.66Z"/><path class="puf-spec-head-2" d="M1.44,24.39C-.14,44.72,12.26,89.99,31.11,88.31c19.13-1.7,31.64-19.15,31.64-38.36,0-25.5-15.09-42.49-34.3-42.49-15.94,0-4.27,10.96-27.02,16.94Z"/><circle class="puf-spec-head-3" cx="56.55" cy="65.07" r="4.83"/><g><g><path d="M21.27,34.63c.39,6.01,5.72,10.68,11.98,9.99,4.93-.54,8.94-4.55,9.48-9.48,.68-6.16-3.84-11.43-9.72-11.96-.64-.06-1.83-.18-1.81,.46,.08,3.19-4.22,8.09-9.55,9.68-.45,.13-.4,.85-.37,1.31Z"/><circle class="puf-spec-head-2" cx="37.2" cy="33.78" r="5.61"/><circle cx="38.86" cy="33.75" r="3.93"/><circle class="puf-spec-head-2" cx="37.01" cy="35.72" r="1.62"/></g><path d="M28.55,44.11c-2.41-.69-15.45-8.35-20.65-9.61,11.88-1.18,20.31,7.09,20.31,7.09l.33,2.52Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.puf-head-1{fill:#fff;}</style></defs><circle class="puf-head-1" cx="36.98" cy="33.75" r="5.89"/><g><path d="M14.82-.05C25.28-.05,34.99,3.17,43.02,8.66c6.43,1.63,53.07,20.19,55.02,60.45-6.75-2.8-10.57-6.13-16-6.46-4.81-.29-13.9,2.29-19.89,3.42l-.03,.1c2.14,.19,4.83,.25,7.67,.3,4.63,.09,9.88,.19,12.94,.94,.21,.05,.4,.1,.59,.16,2.66,.83,4.74,2.6,7.3,4.84-8.7,6.63-25.53,17.12-41.91,14.28-8.91,8.22-20.81,13.24-33.89,13.24-5.58,0-14.82,.1-14.82,.1V.05S9.42-.05,14.82-.05ZM31.11,88.36c4.24-.38,8.15-1.53,11.68-3.3,6.56-3.3,11.76-8.74,15.21-15.35-.46,.14-.94,.24-1.44,.24-2.24,0-4.1-1.53-4.65-3.6-.09-.32-.14-.65-.15-1,0-.08-.02-.15-.02-.23,0-2.67,2.16-4.83,4.83-4.83,1.85,0,3.44,1.05,4.25,2.58,1.27-4.08,1.95-8.43,1.95-12.88,0-10.34-2.48-19.27-6.71-26.25C49.84,13.52,39.87,7.51,28.45,7.51c-15.94,0-4.27,10.96-27.02,16.94C-.14,44.77,12.26,90.04,31.11,88.36Z"/><g><g><path d="M21.74,33.31c5.33-1.58,9.64-6.49,9.55-9.68-.02-.64,1.17-.52,1.81-.46,5.19,.47,9.32,4.63,9.75,9.83-.38-2.73-2.71-4.83-5.55-4.83-3.1,0-5.61,2.51-5.61,5.61s2.51,5.61,5.61,5.61c2.87,0,5.23-2.15,5.56-4.93-.01,.23-.01,.45-.04,.68-.54,4.93-4.55,8.94-9.48,9.48-6.25,.69-11.58-3.98-11.98-9.99-.03-.46-.07-1.18,.37-1.31Z"/><path d="M38.74,35.72c0-.9-.73-1.62-1.62-1.62-.85,0-1.54,.65-1.61,1.48-.29-.55-.47-1.17-.47-1.83,0-2.17,1.76-3.93,3.93-3.93s3.81,1.66,3.91,3.74c0,.09,.01,.18,.01,.27-.04,2.14-1.78,3.86-3.93,3.86-.58,0-1.13-.13-1.63-.36,.79-.11,1.4-.78,1.4-1.6Z"/></g><path d="M28.65,44.11c-2.41-.69-15.45-8.35-20.65-9.61,11.88-1.18,20.31,7.09,20.31,7.09l.33,2.52Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.bee-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="bee-cls-1" cx="60.27" cy="42.12" rx="11.72" ry="11.98"/></g><g id="art"><path d="M34.08,0H0V100c9.42,0,33.21.06,34.16,0-11-12-18.08-29.92-18.08-50S23.09,12,34.08,0Z"/><path d="M91.4,18a6.53,6.53,0,0,0-9,2.05l-.15.27a20.68,20.68,0,0,0-9.69-.43,62,62,0,0,0-6.81-6.56,21.23,21.23,0,0,1,5.82-2.77A7.28,7.28,0,1,0,70.8,7.3c0,.11,0,.22,0,.34a23.68,23.68,0,0,0-7.53,3.74A60.16,60.16,0,0,0,46,2.37C34.1,12,26.08,29.73,26.08,50c0,20.44,8.15,38.31,20.23,47.87A63.22,63.22,0,0,0,70.67,84.44C65.78,80.93,59.49,75,61,70.31c3.6,4.06,9.9,5.86,17.14,5.74a40.18,40.18,0,0,0,7.62-23.41c0-11.32-4.37-21.79-11.21-30.37a18.73,18.73,0,0,1,6.87.58A6.53,6.53,0,1,0,91.4,18ZM65,50a7.7,7.7,0,0,0,3.29-.74,10.08,10.08,0,1,1,0-14.18A7.7,7.7,0,0,0,65,34.29,7.83,7.83,0,1,0,65,50Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 904 B |
@@ -0,0 +1,3 @@
|
||||
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path d="M0 100h56L32 88l-5-14 73 2-10-48L50 0H0zm23-61a9 9 0 1 1-10 10 9 9 0 0 1 10-10z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M25.91 21.49a3.65 3.65 0 0 1 .58.05 9.64 9.64 0 1 0 0 13.8 3.65 3.65 0 0 1-.58.05c-2.75 0-4.9-3.05-4.9-6.95s2.15-6.95 4.9-6.95z"/>
|
||||
<path d="M100 43.78v-8.7a35 35 0 0 0-35-35L0 0v100h65.6c18.49 0 33-10.34 34.23-28.5h-7.24V43.78zm-80.22-2.7a12.64 12.64 0 1 1 12.63-12.64 12.65 12.65 0 0 1-12.63 12.64zM50 57.64a13.85 13.85 0 0 1 10.33-13.39V71A13.85 13.85 0 0 1 50 57.64zM64.6 71.5V43.78h9.74V71.5zm23.73 0h-9.74V43.78h9.74z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 523 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.bird-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="bird-cls-1" cx="47.62" cy="29.37" r="9.78"/></g><g id="art"><circle cx="45.4" cy="27.15" r="4.23"/><path d="M98.15,30.9,82,25.78a57.29,57.29,0,0,1,5.07,19.41l9.81-11.86-9.71-.86Z"/><path d="M39.83,0,0,0V100s6.79-9,37.85-9S83.32,74.29,85,50.08V50a53.33,53.33,0,0,0-6.61-25.91C70.43,9.82,56.16.24,39.83,0ZM51.33,66c-.72,15.09-22.71,17.54-37.95,3.45,4.45-.29,11.07-.86,12.22-1.87C21,66.25.73,59.19,5.18,40.87c2.73,3,9.06,6.1,17.11,8S52.18,48.1,51.33,66ZM47.62,39.15a9.78,9.78,0,1,1,9.78-9.78A9.77,9.77,0,0,1,47.62,39.15Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 671 B |
@@ -0,0 +1,18 @@
|
||||
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<g id="Layer_2_1_">
|
||||
<path d="M99.8,41c-1.2-8.6-13.8-7.5-15.4-2.9c0,0-0.3-4.1-4.8-9.7h0.1l-0.3-1.1c-0.6-2-1.4-3.8-2.4-5.6c-0.9-1.6-2-3.1-3.2-4.4
|
||||
c-0.9-0.9-1.9-1.8-3-2.4c1.1-2-0.3-5.6-1-6.9c-1.5-2.7-3.9-4.8-6.9-5.8c-2.8-0.9-5.7-0.8-8.9-0.7c-2.3,0.1-4.7,0.1-7,0.1h-2
|
||||
c-5.7,0.1-11.3-0.1-17-0.6c-4.5-0.4-10.1-1-15.3,0.1L12,1.3C8.2,0.8,4.2,0.4,0,0v100c10.8,0.1,21.7-0.1,32.5-0.5
|
||||
c9.4-0.4,18.8-0.9,28.1-1.9c8.4-0.9,20.7-0.7,28.1-4.5c6.7-3.4,9.8-10.4,7-17.1c0,0-20.7,20.4-45.7-9.2c0,0,48.1,3.5,45.6-5
|
||||
c-0.9-3.5-2.3-6.9-4.2-10C91.5,51.8,101,49.7,99.8,41z M70.3,15.9c1,0.6,1.8,1.3,2.7,2.1c1.2,1.2,2.2,2.6,3,4.2
|
||||
c0.8,1.4,1.5,2.8,2,4.3l0,0c-2.3-1-7.5-3.4-9-3.9C64.3,20.9,52.6,19,46.8,24c-0.3,0.2-0.5,0.5-0.8,0.8c1.1-3.4,3.4-6.4,6.5-8.2
|
||||
c2.6-1.4,5.5-2.2,8.5-2.1C64.2,14.4,67.4,14.9,70.3,15.9z M29.1,77.7c-5.1,0-9.2-4.1-9.2-9.1c0-4.7,3.5-8.7,8.2-9.2
|
||||
c0.2,0,0.4,0,0.6,0h0.4h0.1c0.6-3.4-0.2-6.9-2.1-9.7c-1.9-2.8-4.1-5.3-6.5-7.6c-0.6-0.6-1.2-1.2-1.7-1.7
|
||||
c-8.7-8.9-13.3-17.7-13.6-26C5,11,6.2,4.1,11.5,2.5c0.5-0.1,0.9-0.2,1.4-0.3c0.8-0.2,1.6-0.3,2.5-0.4c1.3-0.1,2.7-0.2,4-0.2
|
||||
c2.8,0,5.7,0.2,8.5,0.6C33.5,2.6,39.3,2.8,45,2.7h2c2.3,0,4.7,0,7.1-0.1c3.1-0.2,5.9-0.3,8.5,0.6c2.7,0.9,4.9,2.8,6.3,5.3
|
||||
c0.8,1.6,1.8,4.9,0.8,6.1c-3.4-1-7-1.4-10.6-1.1c-2.5,0.2-4.8,1-7,2.2c-3.9,2.3-6.5,6.1-7.8,11.5c-0.6,1.1-1.2,2.3-1.6,3.5
|
||||
l-0.5,1.2c-0.6,2-1.5,3.9-2.7,5.6c-0.7,1-1.6,2-2.4,2.9c-2.2,2.4-4.3,4.8-3.2,8.6c1.2,4.2,0.6,8-1.7,10.9l0.5,0.2l0.5,0.2
|
||||
c4.5,2.3,6.4,7.8,4.1,12.3C35.8,75.8,32.6,77.7,29.1,77.7z M61,41.1c-5.2,1-8.4,7-9.4,1.8s1.7-14.2,6.9-15.1s10.9,6.4,11.9,11.6
|
||||
S66.2,40.1,61,41.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.bull-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="bull-cls-1" d="M69.29,61.82s-2,3.19-2.8,4.29c-3,.1-4.67,1.75-6.53,3.62.54,1.42.33,6.14-.55,9.87C66.43,73.07,69.29,61.82,69.29,61.82Z"/></g><g id="art"><path d="M54,16.43c10.6-9.18,20.9-2.13,29.81-2.26C94.7,14,95.86,5,99.26.92c1.11,5.94.4,18.32-5.09,23.71s-11.36,3.26-17.27,4.1a10.54,10.54,0,0,0-5.52,2.52q1.94,1.84,3.81,3.68C92.09,51.4,70.33,79.28,61.8,85.86c-1.8,1.39-3,6.31-3.45,9.82a3.29,3.29,0,0,1-3.12,2.84c-15.79.6-30-6.21-33.37-12a4.91,4.91,0,0,1-.41-3.91c.68-2.16,3.8-7.56,5.95-11.55a3.92,3.92,0,0,0-1-4.86c-5.47-4.65-4.57-14.51-1.68-18.17C15.28,49.17,5.27,65.79,0,100V0C24.28,0,40.86,7.1,54,16.43ZM69.29,61.82s-2,3.19-2.8,4.29c-3,.1-4.67,1.75-6.53,3.62.54,1.42.33,6.14-.55,9.87C66.43,73.07,69.29,61.82,69.29,61.82Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,3 @@
|
||||
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M54.86,61.78H100V35.07a35,35,0,0,0-35-35L0,0V100H65c18.49,0,33.62-10.34,34.9-28.5h-45a4.86,4.86,0,0,1,0-9.72ZM42.5,41.33c-1.88,4.51-5.78,7.93-11.27,9.88a1.31,1.31,0,0,1-.43.08,1.29,1.29,0,0,1-.43-2.5c4.78-1.71,8.15-4.63,9.75-8.45a14.81,14.81,0,0,0-.59-12.06,13.35,13.35,0,0,0-9.39-6.88,11.88,11.88,0,0,0-10.42,2.3,11.08,11.08,0,0,0-1,15.64,8.61,8.61,0,0,0,12.15.78,6.64,6.64,0,0,0,.6-9.36,5,5,0,0,0-7.12-.45A3.77,3.77,0,0,0,24,35.64a2.77,2.77,0,0,0,3.9.25,1.92,1.92,0,0,0,.66-1.34,2,2,0,0,0-.48-1.42,1,1,0,0,0-.67-.23h0A.71.71,0,0,0,27,33a1.28,1.28,0,0,1-1.35,1.23,1.29,1.29,0,0,1-1.22-1.35,2.63,2.63,0,0,1,1.84-2.38,3.6,3.6,0,0,1,3.76.92,4.54,4.54,0,0,1-.41,6.4,5.36,5.36,0,0,1-3.87,1.32,5.3,5.3,0,0,1-3.67-1.81,6.36,6.36,0,0,1,.58-9,7.63,7.63,0,0,1,10.76.69,9.23,9.23,0,0,1-.83,13,11.21,11.21,0,0,1-15.79-1A13.67,13.67,0,0,1,18,21.76a14.42,14.42,0,0,1,12.64-2.88,15.73,15.73,0,0,1,11.21,8.31A17.36,17.36,0,0,1,42.5,41.33Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1020 B |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m62.81 43.67a6.33 6.33 0 0 0 -3.09 11.85 8.38 8.38 0 0 1 -1.75.19 8 8 0 1 1 7.09-11.62 6.47 6.47 0 0 0 -2.25-.42z" fill="#fff"/><path d="m90.29 68.22-3.92-5.81-6.4 6a3.66 3.66 0 0 0 -1.14 2.59 3.59 3.59 0 0 0 1 2.62l5.84 6.2a3.65 3.65 0 0 0 1.63 1c.78-.91 1.51-1.79 2.18-2.6a8.69 8.69 0 0 0 .81-10z"/><path d="m77.64 75.67a6.67 6.67 0 0 1 .28-9.42l6.75-6.36-.29-.43a22.45 22.45 0 0 1 -3.79-11.18l-.7-8.83c-.24-3.84-.84-9.66-3.52-12.41a85.64 85.64 0 0 0 -15.63-12.82 71.79 71.79 0 0 0 -19.15 6.88c5.41-7.89 31.6-17.73 31.6-17.73-9.59-2.81-73.19-3.37-73.19-3.37v100c9.47-15.78 25.11-16.55 31.26-16.11 2.64.18 16.44 11.55 29.33 12.39 10.41.72 18.67-6.53 24.61-13.1a6.54 6.54 0 0 1 -1.72-1.31zm-33.64-16.84a1.5 1.5 0 0 1 2.08-.42l12.72 8.49a1.5 1.5 0 0 1 .42 2.1 1.52 1.52 0 0 1 -1.25.67 1.41 1.41 0 0 1 -.83-.26l-12.73-8.5a1.5 1.5 0 0 1 -.41-2.08zm12.48 20.48a1.51 1.51 0 0 1 -1.48 1.34h-.17l-17.49-2a1.49 1.49 0 0 1 -1.34-1.65 1.51 1.51 0 0 1 1.66-1.33l17.48 2a1.49 1.49 0 0 1 1.34 1.64zm3.91-4.66a1.49 1.49 0 0 1 -1.92.91l-28.75-10.24a1.5 1.5 0 0 1 1-2.83l28.76 10.24a1.5 1.5 0 0 1 .91 1.92zm2.42-31a6.33 6.33 0 0 0 -3.09 11.85 8.38 8.38 0 0 1 -1.75.19 8 8 0 1 1 7.09-11.62 6.47 6.47 0 0 0 -2.25-.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.chef-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="chef-cls-1" cx="21.26" cy="28.86" r="16.67"/></g><g id="art"><path d="M80.31,32.64l1.82-2.49a58.71,58.71,0,0,0-7-7.2c-3-2.43-7.81-4.12-9.12-4.55l-1.78,2.43a44.05,44.05,0,0,1,8.69,4.86A80.42,80.42,0,0,1,80.31,32.64Z"/><path d="M94.77,13.62l-.32-.26a7.31,7.31,0,0,0-1.11-.65c-.22-.11-.44-.21-.65-.29a7.19,7.19,0,0,0-4.44-.09c.92-.9,2.94-1.34,4.27-1.52A9.75,9.75,0,0,0,80.9,2.92c.45,1.47.83,3.3.34,4.27a7.44,7.44,0,0,0-1.27-4,7.54,7.54,0,0,0-.67-.75L78.88,2l-.11-.1c-.14-.12-.26-.25-.41-.36s-.22-.14-.33-.21a7,7,0,0,0-7.92,11.44l-2.88,3.93a33.13,33.13,0,0,1,9.13,4.69,57,57,0,0,1,7,7.09l2.86-3.9a7,7,0,0,0,8.56-11ZM91.25,25.37c-3.23.5-6.19-1.34-6.61-4.1a3.88,3.88,0,0,1,0-.48c.66,2.49,3.46,4.1,6.5,3.63s5.23-2.83,5.12-5.42a3.94,3.94,0,0,1,.1.47C96.75,22.23,94.47,24.88,91.25,25.37Z"/><path d="M0,0V100c27.61,0,92.29,1.89,92.29-35.35C92.29,21.91,18.39,0,0,0ZM44.09,80.76c-2.49.29-7.65-.62-12.42-2.07-.41-2.51,1.57-8.84,3.28-11.31a1,1,0,0,0-.26-1.4,1,1,0,0,0-1.39.26A26.59,26.59,0,0,0,29.62,78c-4.21-1.47-7.71-3.32-8.1-5,0,0,.59-16.22,13.64-15.5C43.06,57.92,47.62,67.21,44.09,80.76ZM20.69,14.34A14.91,14.91,0,0,1,29,16.83a8.92,8.92,0,0,0-1.16-.08,9.93,9.93,0,0,0-9.93,9.93,9.56,9.56,0,0,0,.23,2.09A5.68,5.68,0,0,1,26.4,36.5a10.71,10.71,0,0,0,1.39.11,9.9,9.9,0,0,0,7.39-3.32A15,15,0,1,1,20.69,14.34ZM73.28,79.19c-7,4.08-16.79,6.26-26.61,6.26C34,85.45,21.2,81.79,14.33,73.91l-.14.16a9.25,9.25,0,0,1-6.56,2.8,10.65,10.65,0,0,1-5.26-1.49A1.07,1.07,0,0,1,2,73.92a1.05,1.05,0,0,1,1.45-.39c4.74,2.7,8.06.22,9.27-1,2.63-2.59,3.54-6.7,2.07-9.35a1.07,1.07,0,0,1,1.87-1c1.48,2.67,1.21,6.28-.48,9.3a.76.76,0,0,1,.09.09c10.75,13,40.39,13.86,55.53,5a1.5,1.5,0,1,1,1.51,2.59Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.chicken-cls-1{fill:#fff;}</style></defs><g id="eyes"><circle class="chicken-cls-1" cx="60.96" cy="43.26" r="5.93"/></g><g id="art"><path d="M22.25,25.35c7.18,0,20.44-3.73,32.48-3l.32,0c6.24.43,12.13,2.09,16.64,6.14,1.5-3.33.86-8.69-2-13.76C65.88,8,59.56,4.28,55.58,6.51c-1.88,1.06-2.89,3.27-3,6a27.23,27.23,0,0,0-2.27-3.78C45,1.39,37.11-1.29,32.81,1.8,29.42,4.23,29.33,9.23,32,15.16c-3.7-2.2-7.82-2.56-10.89-.52s-4.23,5.62-3.83,9.67A12.91,12.91,0,0,0,22.25,25.35Z"/><path d="M83.67,82.23c-6.18-5.37-4.32-14.1-4.26-14.41a37.27,37.27,0,0,1-11,14.31c-.06.25-.1.51-.14.76C67,91.52,70.78,98,79.42,98,85.56,98,90.54,88.19,83.67,82.23Z"/><path d="M77.66,66.07a30.41,30.41,0,0,0,1.09-3,34.37,34.37,0,0,0,1-14.34,36,36,0,0,0-2.49-9.64,22.35,22.35,0,0,0-5.61-8C67.18,27,61.29,25.33,55.05,24.9l-.32,0c-12-.76-25.3,3-32.48,3a12.91,12.91,0,0,1-5-1C5.86,22,1,2.08,0,0V100c19.36-.12,48.85-4.82,66.69-19.62A37.27,37.27,0,0,0,77.66,66.07ZM23.24,68.82a16,16,0,0,1-2.76-.23C13.35,67.37,8,61.5,4.69,51.13a4.42,4.42,0,0,1,1.78-5.08c6.87-4.47,23.21-3.07,25-2.16-4.9.17-17.72,1-23.35,4.68A1.41,1.41,0,0,0,7.51,50c5,4.58,11.1,3.74,19.87,4.43-5.95,3.39-14,2.22-17.56,1.44a23.84,23.84,0,0,0,3.47,5.27c2.79.69,6.81,1.34,10.36.21C22.28,63,19.57,64,17.37,64.4A12.17,12.17,0,0,0,21,65.64c5.16.88,10.52-1.52,14.1-3.68A112.58,112.58,0,0,0,45.4,55C44.73,56.22,33.43,68.82,23.24,68.82ZM61,49.19a5.93,5.93,0,1,1,5.93-5.93A5.92,5.92,0,0,1,61,49.19Z"/><path d="M85.61,52.14s8.16-.63,13.3-.94A32.56,32.56,0,0,0,79.8,39.05a36,36,0,0,1,2.49,9.64,34.37,34.37,0,0,1-1,14.34c5.1-.59,14.2-4.25,17.69-8.53Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M99,14.64C88.61,4.21,74.27,0,61.06,0H0V100H61.06c14.06,0,28.55-4.83,38-14.64L63.69,50ZM38.68,45.53C31,45.53,24.8,37.24,24.8,27S31,8.51,38.68,8.51c3.88,0,7.39,2.14,9.9,5.57L35.64,27,48.58,40C46.07,43.39,42.56,45.53,38.68,45.53Z"/></svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1 @@
|
||||
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M48,0H0V100H48c27.61,0,52-22.39,52-50S75.61,0,48,0ZM64,82.85c-.8.06-1.61.1-2.42.1,0,0-45-.42-48.48-.42-2.7,0-5.37-3.29-5.5-6.12,0-.1,0-.21,0-.31a6.85,6.85,0,0,1,6.85-6.86l20.37,0a4.3,4.3,0,0,0-.42-8.57H14.56a10.9,10.9,0,0,1,0-21.79H43.3a3.78,3.78,0,0,0,0-7.56h-14a6.85,6.85,0,0,1-6.86-6.85V23.9a6.86,6.86,0,0,1,6.86-6.85h32.2c.81,0,1.62,0,2.42.1a32.94,32.94,0,0,1,0,65.7Z"/><circle cx="68.31" cy="50.05" r="19.12"/></svg>
|
||||
|
After Width: | Height: | Size: 501 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.cosmic-horror-c{fill:#fff}.cosmic-horror-d{fill:#ff7900}</style></defs><g id="a"><path d="M77.68,18.44c-1.54-6.97-7.75-12.18-15.17-12.18-2.97,0-5.74,.85-8.1,2.29-5.21-1.55-10.8-2.9-16.74-4.05C34.07,1.7,29.41,0,24.3,0,21.56,0,18.94,.5,16.55,1.38,11.22,.81,5.7,.35,0,0V100c19.07,0,39.38-.3,56.63-3.49,2.58,1.23,5.51,1.94,8.62,1.94,7.9,0,14.63-4.5,17.35-10.84,7.85-4.71,13.44-11.2,15.67-20.16,5.51-22.18-2.59-38.14-20.58-49.02Z"/></g><g id="b"><g><circle class="cosmic-horror-d" cx="23.09" cy="23.57" r="18.9"/><g><circle cx="28.02" cy="20.72" r="10.82"/><circle class="cosmic-horror-c" cx="22.55" cy="29.21" r="5.24"/><circle class="cosmic-horror-c" cx="31.07" cy="15.53" r="3.01"/></g></g><g><circle class="cosmic-horror-d" cx="16.62" cy="81.63" r="12.82"/><circle cx="16.69" cy="81.64" r="7.34"/><circle class="cosmic-horror-c" cx="14.72" cy="87.72" r="4.22"/><circle class="cosmic-horror-c" cx="19.91" cy="78.54" r="2.04"/></g><g><circle class="cosmic-horror-d" cx="84.1" cy="48.43" r="10.52"/><circle cx="88.96" cy="47.35" r="6.03"/><circle class="cosmic-horror-c" cx="84.85" cy="51.45" r="3.47"/><circle class="cosmic-horror-c" cx="90.93" cy="44.21" r="1.68"/></g><g><circle class="cosmic-horror-d" cx="40.08" cy="55.97" r="12.68"/><circle cx="42.24" cy="50.68" r="7.26"/><circle class="cosmic-horror-c" cx="39.68" cy="56.92" r="4.18"/><circle class="cosmic-horror-c" cx="45.51" cy="47.56" r="2.02"/></g><g><path class="cosmic-horror-d" d="M10.3,44.15c-3.07,0-5.56,2.49-5.56,5.56,0,.38,.04,.75,.11,1.11,.32-.27,.73-.44,1.18-.44,1.01,0,1.83,.82,1.83,1.83,0,.79-.5,1.45-1.19,1.71,.97,.84,2.24,1.35,3.63,1.35,3.07,0,5.56-2.49,5.56-5.56s-2.49-5.56-5.56-5.56Z"/><circle cx="7.66" cy="49.58" r="3.19"/><path class="cosmic-horror-c" d="M7.86,52.21c0-1.01-.82-1.83-1.83-1.83-.45,0-.86,.17-1.18,.44,.25,1.23,.91,2.31,1.82,3.1,.7-.26,1.19-.93,1.19-1.71Z"/><circle class="cosmic-horror-c" cx="8.43" cy="48.08" r=".89"/></g><g><circle class="cosmic-horror-d" cx="61.91" cy="23.65" r="13.85"/><circle cx="64.27" cy="17.87" r="7.93"/><circle class="cosmic-horror-c" cx="60.58" cy="25.05" r="4.56"/><circle class="cosmic-horror-c" cx="67.16" cy="14.6" r="2.21"/></g><g><circle class="cosmic-horror-d" cx="64.52" cy="79.03" r="15.02"/><circle cx="71.47" cy="78.61" r="8.6"/><circle class="cosmic-horror-c" cx="68.79" cy="85.46" r="4.95"/><circle class="cosmic-horror-c" cx="75.09" cy="74.37" r="2.39"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m77.68 18.44c-1.54-6.97-7.75-12.18-15.17-12.18-2.97 0-5.74.85-8.1 2.29-5.21-1.55-10.8-2.9-16.74-4.05-3.6-2.8-8.26-4.5-13.37-4.5-2.74 0-5.36.5-7.75 1.38-5.33-.57-10.85-1.03-16.55-1.38v100c19.07 0 39.38-.3 56.63-3.49 2.58 1.23 5.51 1.94 8.62 1.94 7.9 0 14.63-4.5 17.35-10.84 7.85-4.71 13.44-11.2 15.67-20.16 5.51-22.18-2.59-38.14-20.58-49.02z"/><circle cx="23.09" cy="23.57" fill="#fff" r="18.9"/><circle cx="28.02" cy="20.72" r="10.82"/><g fill="#fff"><circle cx="22.55" cy="29.21" r="5.24"/><circle cx="31.07" cy="15.53" r="3.01"/><circle cx="16.62" cy="81.63" r="12.82"/></g><circle cx="16.69" cy="81.64" r="7.34"/><circle cx="14.72" cy="87.72" fill="#fff" r="4.22"/><circle cx="19.91" cy="78.54" fill="#fff" r="2.04"/><circle cx="84.1" cy="48.43" fill="#fff" r="10.52"/><circle cx="88.96" cy="47.35" r="6.03"/><circle cx="84.85" cy="51.45" fill="#fff" r="3.47"/><circle cx="90.93" cy="44.21" fill="#fff" r="1.68"/><circle cx="40.08" cy="55.97" fill="#fff" r="12.68"/><circle cx="42.24" cy="50.68" r="7.26"/><circle cx="39.68" cy="56.92" fill="#fff" r="4.18"/><circle cx="45.51" cy="47.56" fill="#fff" r="2.02"/><path d="m10.3 44.15c-3.07 0-5.56 2.49-5.56 5.56 0 .38.04.75.11 1.11.32-.27.73-.44 1.18-.44 1.01 0 1.83.82 1.83 1.83 0 .79-.5 1.45-1.19 1.71.97.84 2.24 1.35 3.63 1.35 3.07 0 5.56-2.49 5.56-5.56s-2.49-5.56-5.56-5.56z" fill="#fff"/><circle cx="7.66" cy="49.58" r="3.19"/><path d="m7.86 52.21c0-1.01-.82-1.83-1.83-1.83-.45 0-.86.17-1.18.44.25 1.23.91 2.31 1.82 3.1.7-.26 1.19-.93 1.19-1.71z" fill="#fff"/><circle cx="8.43" cy="48.08" fill="#fff" r=".89"/><circle cx="61.91" cy="23.65" fill="#fff" r="13.85"/><circle cx="64.27" cy="17.87" r="7.93"/><circle cx="60.58" cy="25.05" fill="#fff" r="4.56"/><circle cx="67.16" cy="14.6" fill="#fff" r="2.21"/><circle cx="64.52" cy="79.03" fill="#fff" r="15.02"/><circle cx="71.47" cy="78.61" r="8.6"/><circle cx="68.79" cy="85.46" fill="#fff" r="4.95"/><circle cx="75.09" cy="74.37" fill="#fff" r="2.39"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<style>.cry-7{opacity:.4}.cry-1{fill:#999}.cry-2{fill:#b3b3b3}.cry-3{fill:#565656}.cry-4{fill:#3f3f3f}.cry-5{fill:gray}.cry-6{fill:#666}.cry-11,.cry-9{opacity:.4}.cry-10,.cry-20,.cry-8,.cry-9{fill:#fff}.cry-8{opacity:.2}.cry-10{isolation:isolate;opacity:.23}.cry-11{fill:#232323}.cry-12{fill:#3a3a3a}.cry-13{fill:#d37105}.cry-14{fill:#ba5e02}.cry-15{fill:#823907}.cry-16{fill:#703106}.cry-17{fill:#601d02}.cry-18{fill:#a34f02}.cry-19{fill:#e5801a}.cry-20{opacity:.37}.cry-21{fill:#000000}</style>
|
||||
</defs>
|
||||
<g id="crystals">
|
||||
<polygon class="cry-1" points="52.85 9.84 21.16 53.77 16.23 3.18 52.85 9.84" />
|
||||
<polygon class="cry-2" points="52.89 9.86 100 59.16 21.16 53.79 52.89 9.86" />
|
||||
<polygon class="cry-3" points="87.77 88.89 21.16 53.79 17.76 96.76 87.77 88.89" />
|
||||
<polygon class="cry-4" points="0 59.93 21.16 53.79 17.76 96.76 0 59.93" />
|
||||
<polygon points="0 59.93 0 100 17.76 96.76 0 59.93" />
|
||||
<polygon class="cry-5" points="0 59.93 21.16 53.79 16.19 3.2 0 59.93" />
|
||||
<polygon points="0 59.93 0 0 16.19 3.2 0 59.93" />
|
||||
<polygon class="cry-6" points="100 59.16 55.91 74.03 87.54 88.89 100 59.16" />
|
||||
<polygon class="cry-4" points="17.76 96.76 55.91 74.03 87.54 88.89 17.76 96.76" />
|
||||
<polygon class="cry-5" points="100 59.16 55.91 74.03 21.16 53.79 100 59.16" />
|
||||
</g>
|
||||
<g id="overlay">
|
||||
<g class="cry-7">
|
||||
<polygon points="52.85 9.84 16.23 3.18 21.16 53.77 52.85 9.84" />
|
||||
<polygon points="100 59.16 52.89 9.86 16.19 3.2 16.19 3.2 0 59.93 17.76 96.76 17.76 96.76 87.77 88.89 87.58 88.79 100 59.16" />
|
||||
</g>
|
||||
</g>
|
||||
<g id="shines">
|
||||
<polygon class="cry-8" points="19.28 35.09 19.96 52.96 4.89 58.51 20.1 55.3 19.88 70.69 21.84 55.32 41.51 65.64 23.57 54.68 39.72 55.05 22.78 52.82 25.48 48.26 21.79 51.59 19.28 35.09" />
|
||||
<polygon class="cry-8" points="20.56 49.14 20.56 53.5 17.11 54.93 20.48 54.6 20.8 58.07 21.51 54.66 25.48 56.29 22.33 54.2 24.38 54.06 21.95 53.41 22.83 51.62 21.43 52.89 20.56 49.14" />
|
||||
<polygon class="cry-8" points="73.75 68.05 55.8 73.34 45.32 67.86 54.35 74.07 45.43 79.98 55.85 74.9 66.33 78.81 57.14 73.9 73.75 68.05" />
|
||||
<polygon class="cry-9" points="100 59.16 56.63 13.85 91.98 54.72 67.83 43.77 94.08 57.97 82.96 58.07 95.83 59.76 85.56 64.03 100 59.16" />
|
||||
<path class="cry-10" d="M17.85,8.63a7,7,0,0,0,9.23,4.75c-4,1.29-5.55,6.74-4.75,9.24-.85-2.66-5.49-5.95-9.23-4.76A7.14,7.14,0,0,0,17.85,8.63Z" />
|
||||
<path class="cry-10" d="M85.4,69.63a5.55,5.55,0,0,0,2.86,7.74c-3.05-1.4-6.87,1-7.74,2.86.93-2,0-6.44-2.86-7.74A5.67,5.67,0,0,0,85.4,69.63Z" />
|
||||
</g>
|
||||
<g id="eye">
|
||||
<polygon class="cry-11" points="26.26 26.51 26.26 40.44 38.32 47.4 50.38 40.44 50.38 26.51 38.32 19.55 26.26 26.51" />
|
||||
<polygon class="cry-12" points="26.88 26.88 26.88 40.08 38.32 46.68 49.75 40.08 49.75 26.88 38.36 20.27 26.88 26.88" />
|
||||
<polygon class="cry-13" points="27.87 26.95 27.87 39.09 38.38 45.16 48.89 39.09 48.89 26.95 38.42 20.89 27.87 26.95" />
|
||||
<polygon class="cry-14" points="38.38 33.02 48.91 39.05 48.86 26.91 38.38 33.02" />
|
||||
<polygon class="cry-15" points="38.38 33.02 27.87 26.95 38.42 20.89 38.38 33.02" />
|
||||
<polygon class="cry-16" points="38.38 33.02 27.84 39.05 27.9 26.91 38.38 33.02" />
|
||||
<polygon class="cry-17" points="38.38 33.02 38.38 45.16 27.84 39.04 38.38 33.02" />
|
||||
<polygon class="cry-18" points="38.38 33.02 38.38 45.16 48.91 39.04 38.38 33.02" />
|
||||
<polygon class="cry-19" points="30.28 28.35 30.28 37.7 38.38 42.37 46.48 37.7 46.48 28.35 38.41 23.67 30.28 28.35" />
|
||||
<path class="cry-21" d="M41.89,33c0,4.47-3.47,8.09-3.47,8.09S34.94,37.49,34.94,33s3.48-8.09,3.48-8.09S41.89,28.55,41.89,33Z" />
|
||||
<circle class="cry-20" cx="36.15" cy="30.41" r="2.58" />
|
||||
<circle class="cry-20" cx="40.97" cy="36.22" r="1.33" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.cute-dragon-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="cute-dragon-cls-1" d="M69.92,50.15a11.54,11.54,0,0,0-1.21-3c-2.53-4.81-7.87-7-11.92-4.87s-5.6,8-3.06,12.85a10.11,10.11,0,0,0,1.5,2.14,9.75,9.75,0,0,0,7.17,3.17A9.2,9.2,0,0,0,66,59.7c4.21-1.77,5.06-5.63,4-9.56"/></g><g id="art"><path d="M63.25,46.75a5.64,5.64,0,1,1-5.64,5.64A5.63,5.63,0,0,1,63.25,46.75Zm.35,10.63a.82.82,0,1,0-.82-.82A.82.82,0,0,0,63.6,57.38ZM62.49,53a1.64,1.64,0,1,0-1.64-1.63A1.63,1.63,0,0,0,62.49,53Z"/><path d="M14.71,15.34a1.17,1.17,0,0,0,2-.95L15.49,4.68a2.34,2.34,0,0,1,4-1.53L33.06,17.93a1.17,1.17,0,0,0,2-.67l.79-7.58a2.34,2.34,0,0,1,4.2-1.32c3,4,6.65,9.29,8.8,12.45a1.17,1.17,0,0,0,2.1-.42,19.7,19.7,0,0,0,.47-2.85,2.33,2.33,0,0,1,4.29-1.13c2.07,3.2,4.45,7,5.69,9.14a5.24,5.24,0,0,0,2.15,2.1c3.95,2.09,13.58,8,17.45,10.83,2.69,3.22,4.28,11.11,4.5,16.6a10,10,0,0,0,2.94,6.73c2.21,2.18,5.14,5.08,7.12,7.24a6.17,6.17,0,0,1,1.5,5.4c-.84,4-2.46,9.87-4.49,13.13a4.11,4.11,0,0,1-3.36,1.89,68.5,68.5,0,0,1-11.72-1c-5.27-.88-8.78-9.11-12.08-11.52s-7.9-3-11.52-3.84-6.59-4.39-7.79-6.26l1.09-3.18c-3.62-.11-5.81,2-7.13,4.94l2.3-.55s0,3.18.11,6.25,4.83,6.48,5.27,7.8-.66,5.15,0,7.21,4.06,6.07,6.15,8.59c-29.86,7.24-32.49-30.62-28-36.77-2.85,2.22-10.1,7.25-5.05,23.82C10.58,97.75,0,100,0,100V0Zm72.63,64a2.7,2.7,0,0,0,2.41-.06,2.2,2.2,0,0,1-1.1-.25c-1.41-.75-1.72-2.9-.72-4.81,1.37-2.61,5-1.65,5.22-1.33a2.69,2.69,0,0,0-1.31-2c-1.58-.84-3.86.37-5.11,2.71S85.76,78.48,87.34,79.32ZM62.4,60.41A9.2,9.2,0,0,0,66,59.7c4.21-1.77,5.06-5.63,4-9.56h0a11.54,11.54,0,0,0-1.21-3c-2.53-4.81-7.87-7-11.92-4.87s-5.6,8-3.06,12.85a10.11,10.11,0,0,0,1.5,2.14A9.75,9.75,0,0,0,62.4,60.41ZM54.16,59.2c-6.6-6.81-3.35-16.23,1.38-18.89,5.31-3,15.91.13,16.64,9.46.4-8.56-8.14-13-14.14-12.61-1.92-.77-4-5.68-4.57-8.59-1.52,2.76-1.86,5.8-.58,10a35.75,35.75,0,0,1-4-4.83c-.32,2.42,0,8,1.45,9.07C46.3,49.39,50.64,57.38,54.16,59.2Zm-11-11.32c-4-3.65-9.2-6.81-9.88,6.22C34.7,49.34,36.15,44.23,43.13,47.88ZM5.41,41C4.09,42.22,5.13,47.91,8,51.5c-1.27-3.28-1.36-6.91-.21-8s4.76-.73,8,.78c-2.81-2.57-7.16-4-9.3-3.7-.68-2.7-.56-5.28.37-6.15s3.75-.8,6.51.19a15,15,0,0,0,2.27,4.17,15.77,15.77,0,0,1-.93-3.63l.1,0-.1-.09c-.24-2,0-3.65.71-4.32,1.15-1.06,4.77-.72,8,.79-3.38-3.09-9-4.54-10.3-3.31-.8.74-.73,3.1.07,5.64-3.31-2.18-7.51-3.06-8.63-2s-.53,5.34,1.44,8.81A1.46,1.46,0,0,0,5.41,41Z"/><path d="M14.73,35.23l-.1,0v0Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" d="M18.52 18.86l-5.77 5.78-5.78-5.78-4.35 4.37L8.39 29l-5.77 5.77 4.35 4.36 5.78-5.77 5.77 5.77 4.37-4.36L17.11 29l5.78-5.77-4.37-4.37z"/>
|
||||
<path d="M100 .11L0 0v100h100L56 55.39l44-39.89zM22.89 34.77l-4.36 4.36-5.77-5.77L7 39.14l-4.39-4.37L8.39 29l-5.78-5.77L7 18.86l5.77 5.77 5.77-5.77 4.36 4.36L17.11 29z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1,4 @@
|
||||
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle fill="none" cx="12.52" cy="28.55" r="9.26"/>
|
||||
<path d="M0 100h100L56 55.39l44-39.89V.11L0 0zm12.52-80.71a9.26 9.26 0 1 1-9.26 9.26 9.26 9.26 0 0 1 9.26-9.26z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="Layer_3" data-name="Layer 3"><g id="Layer_8" data-name="Layer 8"><path d="M89.85.15,0,0V99.86c20.33,0,36.5,3.1,49.88-20.14h-10l-5-5-5,5-5-5-5,5-3.59-5L13.1,78A19.93,19.93,0,0,1,1.36,59.8a48.13,48.13,0,0,0,6.22,6.08c5.33,4.35,8.52,4,20,4,17,0,27.79,1.85,46.55-10.3,14.82-9.6,25.6-29.72,25.6-48.27C99.71,5.81,96.11.15,89.85.15ZM29.92,49.55A14.56,14.56,0,1,1,44.48,35,14.56,14.56,0,0,1,29.92,49.55Z"/><path d="M33.14,25.61a10,10,0,1,0,10,10A10,10,0,0,0,33.14,25.61Zm9.35,10A7.49,7.49,0,1,1,35,28.09,7.49,7.49,0,0,1,42.49,35.58Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 610 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.dragon-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="dragon-cls-1" d="M35.15,30a24.31,24.31,0,0,0,4.83,5c-.75,1.9-2,5-2,5.2,0,0,3.33-1.74,5.43-2.78a56.81,56.81,0,0,0,5.11,2.78C33.54,50,27,31.5,35.15,30Z"/></g><g id="art"><path d="M79.83,64.38c-3.29,3.55-11.43,2.76-16,4.86a5,5,0,0,0-.13-4.73c-9.72,8.51-23.92,8.15-27.2,6,1.31-7.75,12.09-20.62,16.69-23.12,5.52-.13,12.08,9.2,16.81,9.2s15.38-6.05,17.48-6.48,7.75,1.58,11.69,2.24C98.88,42,79.83,30.61,79.83,30.61S76.15,36,75.62,38.76c-2.36.26-8.54-.79-11.56-1.45,0,0,3.16-4.47.79-8.14-17.61,1.31-25.23-6.31-33.64-13.54C20.05,18.26,15,9.81,12,3.48c-1.67,4.25-2.06,16.62,1.35,20.3C-.85,17.21,7.41,1.81,0,0V100c1.9-5,9.14-24.58,17-31.81-2.89,2.63-2.49,8.41-3.15,12.48a56.38,56.38,0,0,1,10.38-9.2c-2.63,3.68-3.55,13.67-2.36,17.09,5.78,3.28,24.57-7.1,31-7.1S64.59,83,66.56,86.32c-.53,2.1-2.24,4.86-4.21,8,15.9-5,29.7-21.16,30.88-30.22-4.34,3.8-8.8,7.88-15,8.93C78.91,72.26,79.3,69.37,79.83,64.38Zm-.12-25.27c2.37.78,6.56,4.38,7.21,5.3-2.49,1.71-7.35,2.23-8.8,2.23a14.82,14.82,0,0,0,4.81-2.93C82.14,42.53,79.71,39.11,79.71,39.11ZM14.3,40c-1.61,1.1-5.13,4.54-6.3,5.42.52.51,3.52,1.83,6.22,3.22-.8,1.46-2.05,4.16-3.73,7.24,1.1-.37,7.32-1.39,10.47-2.85-1.47,2.78-11.57,6.22-13.91,7.17C7.35,58,9,51.84,10.86,50c-1.61-.3-4.61-2.7-7.17-4.53.66-1.32,5.34-4.83,7.9-6.36a80.58,80.58,0,0,0-6-8.49c9.29-1.54,12.91.53,18.58,3.95A65.34,65.34,0,0,0,9.69,32C9.32,31.93,13.71,37.5,14.3,40ZM35.15,30a24.31,24.31,0,0,0,4.83,5c-.75,1.9-2,5-2,5.2,0,0,3.33-1.74,5.43-2.78a56.81,56.81,0,0,0,5.11,2.78C33.54,50,27,31.5,35.15,30Z"/><path d="M88.13,50.89a8.22,8.22,0,0,0-3.52,1.9,35,35,0,0,1,2.64,8.05c3.51-3.3,4.39-9.34,4.39-9.34A14.36,14.36,0,0,0,88.13,50.89Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M87.78,43.89l9.66,4.83a1.43,1.43,0,0,1,0,2.56L86.38,56.81A28.84,28.84,0,0,0,87.78,43.89Z"/><path d="M18.76,9.38c6.69,24.41-7.57,76.85-16.2,89.34L0,100V0Z"/><path d="M7.16,97.18c11.87-18,22.5-64.71,15.67-86.52-.14-.47-.3-.92-.46-1.36S22,8.38,21.86,8L43,19.21c.1.12.19.26.29.38a11.1,11.1,0,0,1,1.09,1.82C50.9,34.82,42.08,72.49,35.55,83a6.92,6.92,0,0,1-2.35,2.58C32.36,86,14.46,95,7.9,98.3L5.51,99.5c.39-.49.78-1,1.17-1.59C6.84,97.68,7,97.42,7.16,97.18Z"/><path d="M43.72,78.14c7-15.59,11.85-38.61,9.21-51.68-.12-.58-.25-1.15-.4-1.69s-.23-.8-.35-1.18l15.59,7.8c0,.09,0,.19.08.28q.34,1.23.6,2.55c1.91,9.93-.14,23.82-5.16,34.13-.57,1.19-1.18,2.34-1.83,3.42L44,80.52l-1.82.9q.33-.65.66-1.32C43.12,79.46,43.42,78.81,43.72,78.14Z"/><path d="M70.71,63.1c2.9-6.71,5.35-16.22,4.54-23.94-.06-.54-.13-1.08-.23-1.61-.06-.37-.11-.74-.2-1.1l8.39,4.2c0,.09,0,.19.07.28a20.1,20.1,0,0,1,.46,2.48,28.89,28.89,0,0,1-2,14.21,16.29,16.29,0,0,1-2,3.51L71,65.47l-1.83.91c.23-.42.46-.87.68-1.34S70.42,63.78,70.71,63.1Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.duck-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="duck-cls-1" cx="35.25" cy="34.41" rx="8.64" ry="14.75" transform="translate(-10.05 15.06) rotate(-21.19)"/></g><g id="art"><path d="M95.63,26.09a1.84,1.84,0,0,0-1.65-1,1.81,1.81,0,0,0-1.31.56A28.92,28.92,0,0,1,72,34.47a17,17,0,0,1-6.58-1.1,18.68,18.68,0,0,0-1.9-.74,8.65,8.65,0,0,0-2.55-.5c-3.83,0-2.73,4.6-1.7,5.85C67,47.33,55.84,58.06,50,65.24a5.09,5.09,0,0,0,2.59,8.08c1.51.41,3.21.73,5,1,6.95,1.19,15.83,2.37,23.84,9.42a1.62,1.62,0,0,0,1.08.41,2.09,2.09,0,0,0,2-1.72c1.23-7.13-4.37-14.56-16-17.23C95.43,63.82,102.76,40.22,95.63,26.09Z"/><path d="M46.27,70.87a8,8,0,0,1,1.41-7.52c.71-.87,1.46-1.76,2.27-2.71,6.95-8.17,11.88-14.89,7-20.75-1.22-1.47-2.41-5-1.1-7.74a5.36,5.36,0,0,1,5.11-3,9,9,0,0,1,1.66.18C55.37,5.51,31.41,0,0,0V100s5.39-.06,11.79,0c4.57,0,8.73-2.5,11.54-6.1,7-9,31.51-2.61,31.51-17-1.06-.2-2.08-.43-3-.69A8.1,8.1,0,0,1,46.27,70.87Zm-5.69-22.7c-4.45,1.72-10.45-3-13.39-10.64s-1.73-15.15,2.73-16.88,10.44,3,13.39,10.64S45,46.44,40.58,48.17Z"/><path d="M36.21,33.87c-1.86.72-2.37,3.88-1.14,7.05s3.73,5.16,5.59,4.44,2.37-3.88,1.14-7S38.07,33.15,36.21,33.87Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path d="M91.7,62.4l-1.4,0.7l-9.1-8.5l-6.8,8l-8.1-8h-5.7l5.5-9.7l7.6,9.7l7.6-9.7l7.2,9l0.6,0.7l10.1-7.9l-9.8-17L18.9,6.3
|
||||
l3.6,37.4c9,2,14.6,10.9,12.5,19.9S24.1,78.1,15.1,76.1S0.6,65.1,2.6,56.2c1.3-5.6,5.3-10.1,10.7-12L9.7,3.2L0,0v100l100-15.8V57.9
|
||||
L91.7,62.4z M27.2,24.2c-0.1,0.3-0.4,0.6-0.7,0.6H26c-0.4,0-0.7-0.3-0.7-0.7c0-0.1,0.3-2.6,3.9-5.8c2-1.8,4.4-3.1,7-3.8
|
||||
c0.4-0.1,1.1,0,1.2,0.3s0,1-0.4,1.1c-2.4,0.4-4.6,1.5-6.3,3.1C27.5,21.9,27.2,24.2,27.2,24.2z M39.4,37.8c-5.1,0-9.3-4.1-9.3-9.3
|
||||
s4.1-9.3,9.3-9.3s9.3,4.1,9.3,9.3c0,0,0,0,0,0C48.6,33.7,44.5,37.8,39.4,37.8C39.4,37.8,39.4,37.8,39.4,37.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 682 B |
@@ -0,0 +1 @@
|
||||
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M71.08,79.45c-3.59,1-50-13.39-50-13.39s5.62,1.25,22.53-10S59.88,67.6,84.83,31.46c0,0,1.17,19.68,1.46,24,5,.3,24.29-29.57,5.88-55.45,0,0-2.32,7.86-16.12,11.56C43,20.41,48.69,0,0,0V100c20.16,0,89.61-.09,96.78-4.18s-5.07-28.66-5.07-28.66S81.32,78.57,71.08,79.45ZM25,27.29a10.8,10.8,0,0,1,7-8.07,11.46,11.46,0,0,0-1,4.46c0,5.6,4.86,10.14,4.86,10.14s4.87-4.54,4.87-10.14a11.08,11.08,0,0,0-.8-4,10.74,10.74,0,1,1-15,7.63Z"/></svg>
|
||||
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.egg-cls-1{fill:#fff;}</style></defs><g id="art"><path d="M76.61,26A9.54,9.54,0,1,1,61.87,13.9C48.68,6,29,0,0,0V100c39.19,0,87.8-22.29,87.8-50C87.8,46.32,85.61,36.17,76.61,26Z"/><path class="egg-cls-1" d="M69,29.84A9.55,9.55,0,0,0,76.61,26,61.41,61.41,0,0,0,61.87,13.9,9.54,9.54,0,0,0,69,29.84Z"/><path class="egg-cls-1" d="M18.61,39.93A14.71,14.71,0,1,1,33.32,25.22,14.71,14.71,0,0,1,18.61,39.93Z"/><circle class="egg-cls-1" cx="13.2" cy="73.42" r="6.59"/><path class="egg-cls-1" d="M54.57,68.8a9.55,9.55,0,1,1,9.54-9.54A9.54,9.54,0,0,1,54.57,68.8Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 637 B |
@@ -0,0 +1,2 @@
|
||||
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 80 B |
@@ -0,0 +1,3 @@
|
||||
<svg id="root" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path d="M100 41V14L0 0v100h74C44 90 32 82 26 64c32-1 55-13 74-23zm-74 0a9 9 0 1 1-13-13c1-1 14 12 13 13zm3-9L6 9l36 22-4 2a7 7 0 0 1-9-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 224 B |
@@ -0,0 +1,4 @@
|
||||
<svg id="root" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M92.11 43.77l-.34-1.42a26.91 26.91 0 0 0-9-14.32l10.68-6.33 1.15 4.82a18.28 18.28 0 0 1-.08 9.67z"/>
|
||||
<path d="M0 100h100L56 55.39l44-39.89V.11L0 0zm21.78-71.45a9.26 9.26 0 1 1-9.26-9.26 9.26 9.26 0 0 1 9.26 9.26z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.ferret-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="ferret-cls-1" d="M33,51.84c3.51,5,13.53,11.12,24.87.8a28.54,28.54,0,0,1-4-3A7.83,7.83,0,1,1,43.68,38.75,15.93,15.93,0,0,1,41,33.4C29.21,32.3,29.5,46.79,33,51.84Z"/></g><g id="art"><path d="M50,12.57C62.28,20.74,69.75,40.2,75.89,43.71c4.92,2.81,16,8.71,20.93,11.19a3.82,3.82,0,0,1,2,2.36,54,54,0,0,0-12-2h0l-.87,0a5,5,0,0,0-4.64,5.19c.27,5.53,2.51,14,6.58,18.48a19,19,0,0,1-11.25,4.85c-6.75.46-10.83-3.07-14.64-2.63S43.39,84.7,37.26,84.3C25.41,83.52,9.6,74.45,3.6,64.93,6.38,75,18.69,80.7,21.76,82.61,17.81,88.17,0,100,0,100V0C27.31,0,41.92,7.2,50,12.57ZM73.71,63.62l-13.83-2a1.5,1.5,0,1,0-.43,3l13.83,2,.22,0a1.5,1.5,0,0,0,.21-3Zm-8.47,15a1.49,1.49,0,0,0,1.34.82,1.44,1.44,0,0,0,.67-.16l6.92-3.48a1.5,1.5,0,1,0-1.35-2.68L65.91,76.6A1.51,1.51,0,0,0,65.24,78.62ZM47,72.73a1.51,1.51,0,0,0,1.49,1.33h.17L73,71.29a1.5,1.5,0,1,0-.34-3L48.32,71.07A1.5,1.5,0,0,0,47,72.73ZM33,51.84c3.51,5,13.53,11.12,24.87.8a28.54,28.54,0,0,1-4-3A7.83,7.83,0,1,1,43.68,38.75,15.93,15.93,0,0,1,41,33.4C29.21,32.3,29.5,46.79,33,51.84ZM3.31,37C4.77,33,6,28.57,7.77,27.4c2.42.22,6.15,1.75,9.3,2.92a58.08,58.08,0,0,0-3.15-7.53,101,101,0,0,1,18,1.39C28.33,18.62,18,13.57,6,11.52,1.85,15,1.41,29.89,3.31,37Z"/><path d="M86.12,58.29l.69,0c3.54,0,9.2,1.37,11.81,2,.78.2,1.3.34,1.38.35s-3.77,15.23-7.58,18.08A7,7,0,0,1,90,76.9c-3.27-3.67-5.45-11.17-5.71-16.57A2,2,0,0,1,86.12,58.29Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.fish-cls-1{fill:#fff;}</style></defs><g id="eyes"><ellipse class="fish-cls-1" cx="53.75" cy="49.02" rx="10.04" ry="10.26"/></g><g id="art"><path d="M92.29,58.72C90.75,52.26,78.6,32.55,48.67,22.45l-.07-.13C38,18.75,25.85,17,10.19,16.37c15.6,0,27.08,1.15,37.17,3.49a19.83,19.83,0,0,1,.56-9c-2.29-2.21-8.38-4.34-15.6-6.12l-1.1,9s-2.77-7-4.13-10.22c-3.38-.71-6.86-1.34-10.21-1.87.33,2.66,1.36,10.5,1.36,10.5s-3.54-8.35-4.47-11A111.38,111.38,0,0,0,0,0V100c12.39,0,54.21,3.06,78.73-19.11C71.44,76.06,67.27,69.25,66.61,67.5,68.58,63.76,83.29,58.28,92.29,58.72Zm-58.13-21a1.46,1.46,0,0,1,2.06.21A1.52,1.52,0,0,1,36.06,40c-1.17,1.06-7,13.49.16,21.43A1.51,1.51,0,0,1,35.11,64a1.51,1.51,0,0,1-1.12-.5c-3.17-3.53-4.62-8.25-4.2-13.66C30.23,44.21,32.67,38.9,34.16,37.69Zm-13,11.7c.7-9,4.56-16.55,6.43-18.07a1.5,1.5,0,1,1,1.89,2.33c-1.12.91-4.69,7.81-5.33,16-.4,5.07.23,12.33,5.5,18.19a1.49,1.49,0,0,1-1.12,2.5,1.47,1.47,0,0,1-1.11-.49C22.7,64.55,20.53,57.49,21.17,49.39Zm16.1,35.68c-4-2-11.06-5.46-10.91-5.35s8.27,5.63,8,5.63-8.79.73-8.79.73l11.28,1.56A9.82,9.82,0,0,0,37.74,92c-19.31,5.52-27.6-9.7-23.49-21a3.26,3.26,0,0,1,4.89-1.55c10.34,7.11,16,10.12,23.21,11.07A7.37,7.37,0,0,0,37.27,85.07ZM44.8,49a9,9,0,0,1,17.9-.53,5.43,5.43,0,1,0-3,7.2A9,9,0,0,1,44.8,49ZM59,71c1.62,3.26,5.68,11.49,5.4,11.49s-7.54-7.84-10.77-11.34a2,2,0,0,1,.26-3l12.6-9.65-7.27,10A2.44,2.44,0,0,0,59,71Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M29.85,52.48c-.05-.16-.06-.21-.13-.41A68.56,68.56,0,0,1,26.8,39.38c0-.27-.08-.52-.11-.79l-.27-2.14,2.14-.11a6.3,6.3,0,0,0,5.7-5.19l.54-3.88L37.63,30c2.86,2.74,17,13.82,28.82,13.82,5.54,0,9.6-2.43,12.42-7.44a3.92,3.92,0,0,1,3.42-2h.33a15.9,15.9,0,0,1,8,3.48C78.8,20,61.6,10.15,40.41,10.15c-6.41,0-11.48,1.4-15.49,3.82a23.8,23.8,0,0,0-8.18,8.33c-.39.63-.75,1.28-1.09,1.95a40.2,40.2,0,0,0-3,8.25c-.18.73-.35,1.47-.51,2.21a74.71,74.71,0,0,0-1.36,11.85c-.08,1.63-.11,3.26-.11,4.88,0,.31,0,.62,0,.92a26.51,26.51,0,0,0,3.49,12.12c.39.68.79,1.34,1.23,2A39.77,39.77,0,0,0,27.35,77.74a52,52,0,0,0,26.89,8,48.36,48.36,0,0,0,14.49-2.16A44,44,0,0,1,44.46,74,46.72,46.72,0,0,1,29.85,52.48ZM66.42,37.77c-.74,1.51-4,1.44-7.2-.15S54,33.52,54.69,32s4-1.44,7.2.15S67.15,36.27,66.42,37.77ZM49.18,18.14c.6-1.95,4.48-2.47,8.67-1.18s7.09,3.92,6.49,5.86S59.86,25.29,55.67,24,48.58,20.08,49.18,18.14Zm-21.35.06c.43-2,4.25-2.85,8.53-1.92s7.41,3.29,7,5.28-4.26,2.85-8.54,1.92S27.4,20.19,27.83,18.2ZM43.4,78.79c-.74,1.5-3.15,1.83-5.38.74s-3.44-3.21-2.71-4.71,3.15-1.84,5.38-.74S44.14,77.29,43.4,78.79Z"/><path d="M98.74,50.14a48.79,48.79,0,0,0-4.28-5.83c-3.18-3.71-7.53-7.55-12-7.94h-.16a1.94,1.94,0,0,0-1.68,1C77.11,43.55,72,45.8,66.45,45.8c-12.3,0-26.71-11-30.21-14.38a8.31,8.31,0,0,1-7.57,6.92,69.64,69.64,0,0,0,1.55,8.3c.08.33.17.65.25,1,6.87,25.71,26.66,34.12,41.78,34.12.48,0,1,0,1.42,0,9.88-.35,17.45-4.3,17.45-9.23,0-3.48-3.7-5-8.27-5a24.84,24.84,0,0,0-9.95,2.18,45.25,45.25,0,0,1-7.45.65c-21.92,0-25.39-19.2-27.78-26.56,1.6,1.28,3.19,2.43,4.77,3.52l.69,9.13,7.28-.89,2.2,11.75L62,60.54l5.41,9.6,8.11-8.08,6.6,5.26,6.48-10.08a28.29,28.29,0,0,0,9.69-4.54A2,2,0,0,0,98.74,50.14ZM81.67,64.38l-6.27-5-7.5,7.48-5.22-9.25L54,63.78,52,53.25,45,54.12l-.42-5.49c12,7.74,23,10.25,31.93,10.25a45.41,45.41,0,0,0,9.32-.94Z"/><path d="M12.33,65.91l-.44-.79A27.52,27.52,0,0,1,8.17,51.44c0-.63,0-1.25,0-1.87v-.16a81.91,81.91,0,0,1,1.68-16c0-.16-.18-.32-.15-.47A40.59,40.59,0,0,1,13.85,22c.05-.09.11-.67.16-.76l-7.92-.36a1,1,0,0,1-.91-1.07,1,1,0,0,1,.1-.38l.08-.1a1.25,1.25,0,0,1,.15-.19L5.64,19a1.18,1.18,0,0,1,.19-.11.9.9,0,0,1,.16,0,1.09,1.09,0,0,1,.26,0l6.11.48-4.63-7L2.41,11.13a1,1,0,0,1-.75-1.2,1,1,0,0,1,.16-.34A.34.34,0,0,1,1.9,9.5a.85.85,0,0,1,.17-.16l.12-.07.21-.08.14,0a.87.87,0,0,1,.32,0l3.26.74L3.9,6.59A1,1,0,0,1,4.18,5.2a.91.91,0,0,1,.33-.13l.1,0H5a.93.93,0,0,1,.31.13l.06,0a1.18,1.18,0,0,1,.25.25l5.32,8,2-5.15a.77.77,0,0,1,.19-.3L13.14,8l.2-.14.1,0a.9.9,0,0,1,.27-.07h.07a1,1,0,0,1,.38.07,1,1,0,0,1,.58,1.29l-2.49,6.47,2.67,4a26.58,26.58,0,0,1,8.42-8.15C16.13,1.53,0,0,0,0V100s16.86-1.6,23.81-12.07a25.66,25.66,0,0,0,3.47-7.51C26.1,79.72,25,79,23.84,78.18A40.31,40.31,0,0,1,12.33,65.91ZM1.49,74.05l-.21.44-.11.27L1,75.18l-.09.3c0,.13-.09.27-.12.41s-.06.21-.08.32-.07.28-.1.42L.56,77c0,.14-.06.28-.08.43s0,.21,0,.32L.4,78c.12-2.41.34-4.61.75-5.32a18.18,18.18,0,0,1,6.21-6,34,34,0,0,1-5.12-1.62l-.64-.28,0-.69c0-.36.48-8.67,4.73-14.59C2.12,43.56,1.66,35.26,1.64,34.9l0-.69.64-.28a31.6,31.6,0,0,1,4-1.35,20.87,20.87,0,0,1-5.11-5.22C.8,26.85.6,24.75.46,22.46c0,.35.11.69.19,1l0,.15c.08.35.17.69.28,1,0,0,0,0,0,0a9.31,9.31,0,0,0,.38,1,1.47,1.47,0,0,1,.1.22,8.21,8.21,0,0,0,.52.91A20.43,20.43,0,0,0,8.77,33a33.85,33.85,0,0,0-6.13,1.83s.44,8.87,5,14.64c-4.54,5.76-5,14.64-5,14.64a34,34,0,0,0,7.55,2.08,21.2,21.2,0,0,0-8.21,7c-.14.21-.26.42-.38.63S1.53,74,1.49,74.05ZM17.23,77l-5,7.5,2.49,6.47a1,1,0,0,1-.58,1.29.92.92,0,0,1-.36.07,1,1,0,0,1-.93-.64l-2-5.15-5.32,8a1,1,0,0,1-.84.45,1,1,0,0,1-.83-1.56l2.22-3.33-3.26.74a.83.83,0,0,1-.22,0,1,1,0,0,1-.23-2l5.32-1.22,3.37-5.08L4.77,81.35A1,1,0,0,1,4,80.18a1.16,1.16,0,0,1,.14-.34l.08-.09a1,1,0,0,1,.17-.18l.12-.06a1,1,0,0,1,.21-.09l.14,0a1.07,1.07,0,0,1,.31,0l7,1.36.09,0,3.28-4.93a1,1,0,0,1,.25-.25l.06,0a1.17,1.17,0,0,1,.31-.12h.05a1.41,1.41,0,0,1,.29,0h.1a1.17,1.17,0,0,1,.33.14A1,1,0,0,1,17.23,77Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg id="art" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M98.47,70.71a2,2,0,0,0-.84-1.52,2,2,0,0,0-1.71-.29c-7,2-16.88,3.23-26.35,3.16l1.15-19.65c4.33-2.35,22.82-6.42,23.4-6.6a4.49,4.49,0,0,0,1.57-.72,2.55,2.55,0,0,0,.75-1.53c0-.61,0-2.48-.12-3.62a2,2,0,0,0,1.81-2.07A2,2,0,0,0,95.81,36C94.82,26,80.81,0,50.07,0H0V100H36.86c5.15,0,17.53-8.61,19.28-14.13A80,80,0,0,0,66,87.28a24.61,24.61,0,0,0,2.34,3.28,25.81,25.81,0,0,0,11.22,7.86h0c4.9,1.74,11.19,2.28,18.91.34A2,2,0,0,0,100,96.7ZM47.68,72.89a9,9,0,1,1-5.45-11.47A9,9,0,0,1,47.68,72.89ZM64.91,83.14c-2.33-.23-4.45-.54-6.33-.89a28.6,28.6,0,0,0,1.6-3.71c.33-.94.62-1.91.89-2.88q2.1.2,4.26.3ZM65.57,72c-1.21,0-2.4-.13-3.57-.23.86-4.28,1.35-8.5,2-11.73a7.19,7.19,0,0,1,.3-1.15,9.17,9.17,0,0,1,2.29-3.45Zm.58-57.21c-6.57-3.69-15.85-6.64-24.92-9a48.81,48.81,0,0,1,27.11,6.15c6.48,3.74,15.85,11.74,19.23,21.84A72.4,72.4,0,0,0,66.15,14.74Zm2.76,68.7.43-7.36c3.77,0,7.59-.12,11.29-.45l.69,7.49A78.77,78.77,0,0,1,68.91,83.44Zm12,11.19A22,22,0,0,1,71.4,88L71,87.55a85.84,85.84,0,0,0,10.65-.43l.74,8C81.89,95,81.38,94.81,80.88,94.63ZM84.61,75.2a89.83,89.83,0,0,0,10-1.8l.41,7a75.8,75.8,0,0,1-9.74,2.19Zm1.9,20.66-.85-9.25a80.35,80.35,0,0,0,9.61-2.09l.63,10.72A31.34,31.34,0,0,1,86.51,95.86Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.frog-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="frog-cls-1" d="M35.91,4.65c18.3.08,18.3,27.91,0,28C17.61,32.55,17.61,4.73,35.91,4.65Z"/></g><g id="art"><path d="M83.63,39.48c5-1.13,14.72-.5,14.85-5.45-1.42-8-12.2-11-18.4-13.86C84.5,4.18,61.43-5.74,52.72,8c-8.63-12-29.16-9.16-34.27,4.61C16.52,12.35,2.35.44,0,0V100H43.59c11.55-15.42,13.32-31.32,32-40.47,4.78-2.57,20-6.88,18.11-18.15C71,44,55.54,64.68,33.29,62.6,26,62,15.4,56.86,15.48,47.85h0C33.14,71.38,61.5,44.43,83.63,39.48Zm-5.08-10c1-2.8,8.06,0,6.86,2.76C84.38,35,77.36,32.16,78.55,29.44ZM35.91,4.65c18.3.08,18.3,27.91,0,28C17.61,32.55,17.61,4.73,35.91,4.65Z"/><path d="M32.33,6.58s-9.17,11.58,0,24.12C32.33,30.7,40.39,20.86,32.33,6.58Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 797 B |
@@ -0,0 +1,11 @@
|
||||
<svg id="root" viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style>.gamer-cutout{fill:#ffffff; opacity: 0 }</style>
|
||||
</defs>
|
||||
|
||||
<path class="gamer-cutout" d="M55,60.54c-1.89,1.89-3.43,3.43-.58,6.28s4.39,1.31,6.28-.58,2.16-4.71.59-6.28S56.87,58.64,55,60.54Z"/>
|
||||
<path class="gamer-cutout" d="M41.1,80.88h0l14-12.47-2.58-2.9-14,12.42-6.66-7.5h0l-.15-.17A16.64,16.64,0,0,1,29,73l9.21,10.48Z"/>
|
||||
<path d="M94.85,48c3.3-1.52,4.57-3.36,3.43-8.08a24,24,0,0,0-8-12.35c-5.31-4.47-11.93-7-18.52-9.21C54.38,12.7,35.88,12,18.82,6.29l3.63,37.37a16.62,16.62,0,0,1,9.3,26.6l.16.17,6.66,7.5,14-12.42,2.58,2.9-14,12.47-2.91,2.59L29,73a16.63,16.63,0,1,1-15.74-28.8L9.67,3.24-.07,0V100c0-.73,19.59-2.57,21.47-2.75,12-1.19,24.16-.57,36.21-1.49C67.73,95,78.71,94,84.79,84.7c3.28-5,3.78-10.88,3.91-16.72a2.83,2.83,0,0,0-.32-1.68c-.56-.82-1.74-.85-2.74-.79a70.51,70.51,0,0,1-10.07,0c-4.45-.34-9.85-3.64-10.13-8.48a2.77,2.77,0,0,1,.16-1.25,3,3,0,0,1,1.6-1.38c3.12-1.5,7-1,10.38-1.54A83,83,0,0,0,92.2,49.09C93.19,48.73,94.08,48.39,94.85,48ZM39.31,37.8a9.26,9.26,0,1,1,9.26-9.25A9.25,9.25,0,0,1,39.31,37.8ZM60.67,66.24c-1.89,1.89-3.43,3.43-6.28.58s-1.31-4.39.58-6.28,4.71-2.16,6.29-.58S62.57,64.34,60.67,66.24Z"/>
|
||||
<path d="M31.75,70.26,25.34,63a7.22,7.22,0,1,0-2.82,2.68L29,73A16.64,16.64,0,0,0,31.75,70.26Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.ghost-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="ghost-cls-1" d="M43.92,31a23,23,0,0,1,8.34-12.38L64.83,32a22,22,0,0,1-.66,4.29c-2.09,8-8.24,10.94-13.32,9.61S41.83,39,43.92,31Z"/><path class="ghost-cls-1" d="M82.69,46.13c-4.65.6-9.77-2.71-10.71-10a19.21,19.21,0,0,1-.1-3.87L84.54,21.8a20.53,20.53,0,0,1,6,11.91C91.46,41,87.35,45.53,82.69,46.13Z"/></g><g id="art"><path d="M57.18,39.18a3.85,3.85,0,1,0,0-7.69H57.1a2,2,0,0,1,.08.55,2,2,0,0,1-2,2,2,2,0,0,1-1.37-.56,3.83,3.83,0,0,0,3.34,5.72Z"/><path d="M70.72,9C53.34.69,1.59,0,0,0V100c69,0,90.58-10.1,97.94-43C101.27,42.07,99.1,22.51,70.72,9ZM43.92,31a23,23,0,0,1,8.34-12.38L64.83,32a22,22,0,0,1-.66,4.29c-2.09,8-8.24,10.94-13.32,9.61S41.83,39,43.92,31ZM25.24,76.21a5,5,0,0,1-2.1.47c-1.43,0-3.32-.62-5.34-3l-2.62-3.13c-2.13-2.55-2.7-3.23-3.32-3.85l-.28-.3c-.42-.46-1-1.1-4.59,1.26a1.5,1.5,0,1,1-1.65-2.51c4.75-3.12,6.82-2.56,8.45-.77l.19.19c.71.72,1.3,1.43,3.5,4l2.62,3.13c1.38,1.64,2.77,2.29,3.83,1.78A3.59,3.59,0,0,0,25.4,69.6C24,62.7,20.15,52.83,16.13,52.46,10.84,52,4.5,56.55,3.79,57.22a1.51,1.51,0,0,1-2.12-.07A1.49,1.49,0,0,1,1.74,55c1-1,8.17-6.16,14.66-5.56,3.13.29,6,3.19,8.42,8.64A56.81,56.81,0,0,1,28.34,69C29,72,27.62,75.05,25.24,76.21Zm23.82,6c-18.28-6-17.45-27.51-10.56-30.93,4.75-2.35,7.09.41,10.41,3.91l-1.33,6.32a1.19,1.19,0,0,0,1.87,1.21l4.26-3.15a20.47,20.47,0,0,0,5.48,2.84,18.89,18.89,0,0,0,5,1l1.39,4.74a1.19,1.19,0,0,0,2.22.17l2.62-5.53c4.47-1,7.23-2.49,8.71,2.12C81.46,72.19,67.34,88.22,49.06,82.17Zm33.63-36c-4.65.6-9.77-2.71-10.71-10a19.21,19.21,0,0,1-.1-3.87L84.54,21.8a20.53,20.53,0,0,1,6,11.91C91.46,41,87.35,45.53,82.69,46.13Z"/><path d="M93.65,72.44a12,12,0,0,1-5.59,9.06c.62.61,1.82,1.65,5.44,5.18s6.16.65,5.53-2.38C98.5,81.73,97.06,75.26,93.65,72.44Z"/><path d="M78.79,32.67l-.08,0a3.49,3.49,0,0,0-.56,0,2,2,0,0,1,.17,1A2.1,2.1,0,0,1,76,35.56a2,2,0,0,1-1-.41,2.66,2.66,0,0,0-.11.47,3.43,3.43,0,0,0,6.81.87,3.45,3.45,0,0,0-2.89-3.82Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><style>.glasses-cls-1{fill:#fff;}</style></defs><g id="eyes"><path class="glasses-cls-1" d="M70.47,39.56a4,4,0,0,0-1,7.87,5.23,5.23,0,0,1-1.14.14,5,5,0,0,1-4.09-2.13h0s-7.2-1.7-10.75-7.08c5.3,2.18,8.89,1.47,8.89,1.47-2.07-1-3.12-5.17-3.12-5.17a13.44,13.44,0,0,0,8.46,2.9c.21,0,.41-.06.62-.06a4.23,4.23,0,0,1,.5.05H69v0a5,5,0,0,1,3.84,2.75A4,4,0,0,0,70.47,39.56Z"/></g><g id="art"><path d="M89.44,36V47.91A4.44,4.44,0,0,1,85,52.35H81.54a4.44,4.44,0,0,1-4.44-4.44V34H26.53a5,5,0,0,0-4.93,3.88,5.61,5.61,0,0,0,3.11,6.42A2.25,2.25,0,0,1,25.8,47a2.13,2.13,0,0,1-2,1.3,2.08,2.08,0,0,1-1-.25,9.87,9.87,0,0,1-5.41-11.21,9.56,9.56,0,0,1,9.36-7.19H77.1V21c0-.13,0-.24,0-.36C60.47,1.06,1.5,0,0,0V100c5.58.09,46.09-1.18,67.76-14.53a1.35,1.35,0,0,0-.58-2.5C45.8,80.7,39.14,67,40,62.15c8.49,2.1,30.94,4.37,45.88,5.69l2.41,8.71a1.6,1.6,0,0,0,3,.32l4.31-8.22L99,68.9C101.48,61.6,97.37,48.46,89.44,36Zm-19,3.59a4,4,0,0,0-1,7.87,5.23,5.23,0,0,1-1.14.14,5,5,0,0,1-4.09-2.13h0s-7.2-1.7-10.75-7.08c5.3,2.18,8.89,1.47,8.89,1.47-2.07-1-3.12-5.17-3.12-5.17a13.44,13.44,0,0,0,8.46,2.9c.21,0,.41-.06.62-.06a4.23,4.23,0,0,1,.5.05H69v0a5,5,0,0,1,3.84,2.75A4,4,0,0,0,70.47,39.56Z"/><path d="M81.54,50.21H85a2.3,2.3,0,0,0,2.3-2.3V21A2.31,2.31,0,0,0,85,18.64H81.54A2.32,2.32,0,0,0,79.23,21v27A2.31,2.31,0,0,0,81.54,50.21Z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="art"><path d="M83.38,16.84H81.54a3.1,3.1,0,0,1-3.08-3.09L77,3.92C77,2.23,79.76.84,82.46.84s5.46,1.39,5.46,3.08l-1.45,9.83A3.1,3.1,0,0,1,83.38,16.84Z"/><path d="M45.7,16.84H43.86a3.09,3.09,0,0,1-3.08-3.09L39.33,3.92c0-1.69,2.75-3.08,5.45-3.08s5.45,1.39,5.45,3.08l-1.45,9.83A3.09,3.09,0,0,1,45.7,16.84Z"/><path d="M65.2,19.69H63.36a3.1,3.1,0,0,1-3.09-3.08L58.83,6.78c0-1.7,2.75-3.09,5.45-3.09s5.45,1.39,5.45,3.09l-1.45,9.83A3.09,3.09,0,0,1,65.2,19.69Z"/><path d="M81.54,81.65h1.84a3.1,3.1,0,0,1,3.09,3.08l1.45,9.83c0,1.7-2.75,3.09-5.46,3.09S77,96.26,77,94.56l1.45-9.83A3.09,3.09,0,0,1,81.54,81.65Z"/><path d="M43.86,81.65H45.7a3.09,3.09,0,0,1,3.08,3.08l1.45,9.83c0,1.7-2.75,3.09-5.45,3.09s-5.45-1.39-5.45-3.09l1.45-9.83A3.09,3.09,0,0,1,43.86,81.65Z"/><path d="M63.36,78.79H65.2a3.09,3.09,0,0,1,3.08,3.09l1.45,9.83c0,1.7-2.75,3.08-5.45,3.08s-5.45-1.38-5.45-3.08l1.44-9.83A3.1,3.1,0,0,1,63.36,78.79Z"/><path d="M18.16,68.56C15.45,89.45,8,93.65,0,100V67.29H11.19A20.42,20.42,0,0,1,18.16,68.56Z"/><path d="M18.13,29.81a20.13,20.13,0,0,1-6.92,1.26L0,31H0V0C7.94,6.33,15.41,9.49,18.13,29.81Z"/><path d="M99.48,41.54c0-4.64-5.19-5.86-7.07-11a16.33,16.33,0,0,1-1.26-5.69c0-2.28.77-4.46,2.13-8-4.82,2.05-47.75,7.57-61.71,4.5-2,5.21-7.08,9.5-13.08,11.51a24.43,24.43,0,0,1-3.61.9v2.81h0v4.53h0v5.67h0v4.61H15l-.08,1h0V57h0l0,1h0v3.54H15l-.1,1h0v2a24.27,24.27,0,0,1,3.62.91c6,2,11,6.3,13.07,11.51,14-3.08,56.89,2.45,61.71,4.5-1.36-3.58-2.13-5.75-2.13-8a16.23,16.23,0,0,1,1.26-5.68c1.88-5.13,7.07-6.35,7.07-11,0-1.85-.82-4.24-3-7.64C98.66,45.78,99.48,43.39,99.48,41.54ZM40.16,72.8a5.43,5.43,0,1,1,5.42-5.42A5.43,5.43,0,0,1,40.16,72.8Zm0-36.09a5.43,5.43,0,1,1,5.42-5.42A5.43,5.43,0,0,1,40.16,36.71ZM58,72.8a5.43,5.43,0,1,1,5.42-5.42A5.42,5.42,0,0,1,58,72.8Zm0-36.09a5.43,5.43,0,1,1,5.42-5.42A5.42,5.42,0,0,1,58,36.71ZM77,72.8a5.43,5.43,0,1,1,5.43-5.42A5.42,5.42,0,0,1,77,72.8Zm0-36.09a5.43,5.43,0,1,1,5.43-5.42A5.42,5.42,0,0,1,77,36.71Z"/><rect y="34.03" width="11.2" height="30.23"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |