solvi arcade: game agents that explain every move, running in your browser
Browse files- README.md +68 -4
- app.py +614 -0
- games/__init__.py +0 -0
- games/_ttt_catalog.py +108 -0
- games/explain.py +52 -0
- games/fusion.py +275 -0
- games/maze.py +371 -0
- games/sandbox.py +112 -0
- games/tictactoe.py +131 -0
- index.html +104 -17
README.md
CHANGED
|
@@ -1,10 +1,74 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: solvi arcade
|
| 3 |
+
emoji: 🕹️
|
| 4 |
+
colorFrom: purple
|
| 5 |
colorTo: yellow
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
short_description: Explainable game agents that run in your browser
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# solvi arcade (runs in your browser)
|
| 14 |
+
|
| 15 |
+
Game agents built with [solvi](https://github.com/solvi-ai/solvi) from small Python functions, a few rules and hard
|
| 16 |
+
checks. They play well, and every move comes with its reason: the rule's inputs, the facts computed this turn, and which
|
| 17 |
+
hard check fired, if any. No models, and **no server**: the page runs Python in the visitor's browser with
|
| 18 |
+
[Gradio-Lite](https://www.gradio.app/guides/gradio-lite) (Pyodide, Python compiled to WebAssembly). solvi is pure Python
|
| 19 |
+
on numpy/scipy, so it installs from PyPI straight into the browser.
|
| 20 |
+
|
| 21 |
+
The first visit downloads the Python runtime with numpy, scipy, Gradio and solvi (about 20–30 MB, once; later visits use
|
| 22 |
+
the browser cache). Write your own decision task in the [solvi playground](https://huggingface.co/spaces/solvi-ai/playground).
|
| 23 |
+
|
| 24 |
+
| Tab | What you see |
|
| 25 |
+
|---|---|
|
| 26 |
+
| ❌⭕ Tic-tac-toe | The catalog from `examples/05_tic_tac_toe.py` (vendored), with an extra `reason` rule that names the move (win, block, fork, forcing, center ...). A "you can't beat it" counter and an "explain the position" view. |
|
| 27 |
+
| 👻 Ghost maze | Pac is a catalog of BFS functions plus one scoring rule. The hard check `greedy_is_safe` blocks any step into a ghost's reach while a safer move exists. Turn the check off, take over with the arrows, run the benchmark in the browser. |
|
| 28 |
+
| 🃏 Card fusion lab | Fuse two weapon cards with BLEND or DOMINANT. A balance catalog clamps slow, lifesteal and cooldown (hard checks) and rescales damage when the power is out of band (soft check). |
|
| 29 |
+
| 🛠️ Build your own bot | Edit the maze agent's scoring function and run 20 seeded games against the default bot, in your own browser. |
|
| 30 |
+
|
| 31 |
+
## Maze benchmark (100 seeded games, same scoring rule and ghosts)
|
| 32 |
+
|
| 33 |
+
| agent | games cleared | avg score | dots eaten | lives lost (of 3) |
|
| 34 |
+
|---|---|---|---|---|
|
| 35 |
+
| hard safety check ON | 59% | 764 | 94% | 1.95 |
|
| 36 |
+
| hard safety check OFF | 4% | 531 | 76% | 2.96 |
|
| 37 |
+
|
| 38 |
+
Native Python computes this in about 7 s on one CPU core; the browser, on a button click, in about 20 s, with the same
|
| 39 |
+
numbers.
|
| 40 |
+
|
| 41 |
+
## Layout
|
| 42 |
+
|
| 43 |
+
- `index.html`: loads `@gradio/lite@5.45.0` from jsDelivr, lists the requirement (`solvi>=0.1.1`) and mounts `app.py`
|
| 44 |
+
and `games/*.py` by URL.
|
| 45 |
+
- `app.py`: the Gradio 5 UI (theme and CSS in `gr.Blocks(...)`).
|
| 46 |
+
- `games/tictactoe.py`, `games/maze.py`, `games/fusion.py`: pure game logic and solvi catalogs, the same as in the
|
| 47 |
+
server version of this Space. `games/_ttt_catalog.py` is a vendored copy of the tic-tac-toe example catalog.
|
| 48 |
+
- `games/explain.py`: renders a solvi `Response` as a "why" card.
|
| 49 |
+
- `games/sandbox.py`: runs the visitor's bot code in-process, in a fresh namespace, under a `sys.settrace` guard: a call
|
| 50 |
+
that runs more than 200,000 lines of the bot's code (an infinite loop), or a run longer than 30 s, is stopped. It
|
| 51 |
+
protects against runaway Python loops, not against a loop inside C code, and it is not a security sandbox (the code
|
| 52 |
+
runs in the visitor's own browser tab).
|
| 53 |
+
|
| 54 |
+
### Differences from the server version (`../arcade`)
|
| 55 |
+
|
| 56 |
+
- No threads or subprocesses in Pyodide: the maze benchmark runs on a button click (20 or 100 games) instead of at
|
| 57 |
+
startup, and the bot runs in-process with the guard above instead of a resource-limited subprocess.
|
| 58 |
+
- The tic-tac-toe counter is per tab (there is no shared server to count everyone's games).
|
| 59 |
+
- "Play 50 ticks" uses an async generator with `asyncio.sleep` (a blocking `time.sleep` would stall the worker).
|
| 60 |
+
|
| 61 |
+
### Workaround in `index.html`
|
| 62 |
+
|
| 63 |
+
Gradio-Lite 5.45.0 (the latest release) installs gradio 5.45 with micropip, whose resolver is greedy. With today's PyPI
|
| 64 |
+
it picks `huggingface-hub` 1.x/2.x for `gradio_client` and then fails on gradio's `huggingface-hub<1.0` pin (also
|
| 65 |
+
`anyio<5`, ...), so the app does not start. `index.html` wraps the web worker and filters PyPI's simple index to files
|
| 66 |
+
uploaded before the Gradio-Lite release (2025-09-10), except `solvi`. If a future Gradio-Lite release fixes this, the
|
| 67 |
+
wrapper script can be removed.
|
| 68 |
+
|
| 69 |
+
## Run locally
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
cd solvi/spaces/arcade-lite
|
| 73 |
+
python -m http.server 8080 # then open http://localhost:8080
|
| 74 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""solvi arcade (browser edition) — game agents built from small Python functions, rules and hard checks, explaining
|
| 2 |
+
every move. Runs in the visitor's browser with Gradio-Lite (Pyodide): index.html mounts this file and games/*.py.
|
| 3 |
+
|
| 4 |
+
No threads and no subprocesses in Pyodide: the benchmark runs on a button click, and "Build your own bot" runs the code
|
| 5 |
+
in-process under a sys.settrace guard (games/sandbox.py).
|
| 6 |
+
|
| 7 |
+
Local run with a normal Python (from solvi/spaces/arcade-lite): PYTHONPATH=../../src python app.py"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import html
|
| 12 |
+
import random
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
+
import gradio as gr
|
| 16 |
+
from games import fusion, maze, sandbox, tictactoe
|
| 17 |
+
from games.explain import why_html
|
| 18 |
+
|
| 19 |
+
REPO = "https://github.com/solvi-ai/solvi"
|
| 20 |
+
PLAYGROUND = "https://huggingface.co/spaces/solvi-ai/playground"
|
| 21 |
+
|
| 22 |
+
# ====================================================================================================================
|
| 23 |
+
# Tic-tac-toe
|
| 24 |
+
# ====================================================================================================================
|
| 25 |
+
|
| 26 |
+
CELL_NAMES = ["top-left", "top", "top-right", "left", "center", "right", "bottom-left", "bottom", "bottom-right"]
|
| 27 |
+
EMPTY = " "
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _ttt_counter(stats):
|
| 31 |
+
return (f"### You can't beat it\n"
|
| 32 |
+
f"**You, in this tab:** {stats['games']} games · agent won {stats['agent_wins']} · draws {stats['draws']} · "
|
| 33 |
+
f"**agent lost {stats['agent_losses']}**")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _ttt_cells(game):
|
| 37 |
+
out = []
|
| 38 |
+
for i, ch in enumerate(game.board):
|
| 39 |
+
label = EMPTY if ch == "." else ("❌" if ch == "X" else "⭕")
|
| 40 |
+
out.append(gr.Button(value=label, variant="primary" if i == game.last_agent_cell else "secondary"))
|
| 41 |
+
return out
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _ttt_status(game):
|
| 45 |
+
if game.over:
|
| 46 |
+
return {"agent": "### solvi wins. Try again?", "human": "### You beat solvi! (please report a bug)",
|
| 47 |
+
"draw": "### Draw. That is the best anyone can do."}[game.result]
|
| 48 |
+
return f"### Your move ({'❌' if game.human == 'X' else '⭕'})"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _ttt_why(game, resp):
|
| 52 |
+
if resp is None:
|
| 53 |
+
return "<div class='sv-card sv-dim'>solvi has not moved yet. It explains each of its moves here.</div>"
|
| 54 |
+
r = resp["move"]
|
| 55 |
+
if r.answer in (None, "none"):
|
| 56 |
+
return why_html(resp, tictactoe.cat, ["move", "reason"], title="solvi could not move")
|
| 57 |
+
c = int(r.answer)
|
| 58 |
+
reason = resp["reason"].answer
|
| 59 |
+
title = f"solvi plays {CELL_NAMES[c]} (cell {c}) — {tictactoe.REASONS[reason]}"
|
| 60 |
+
return why_html(resp, tictactoe.cat, ["move", "reason"], title=title)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ttt_position(game, explain):
|
| 64 |
+
if not explain:
|
| 65 |
+
return gr.HTML(visible=False)
|
| 66 |
+
me = game.turn
|
| 67 |
+
resp = tictactoe.explain_position(game.board, me)
|
| 68 |
+
side = "you" if me == game.human else "solvi"
|
| 69 |
+
title = (f"Position seen from the side to move ({'❌' if me == 'X' else '⭕'}, {side}) — "
|
| 70 |
+
f"every fact the catalog computes, and the move solvi would play here")
|
| 71 |
+
if game.over:
|
| 72 |
+
title = "Game over: the hard check board_valid refuses to pick a move on a finished board"
|
| 73 |
+
return gr.HTML(why_html(resp, tictactoe.cat, ["move", "reason", "outcome"], title=title), visible=True)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _ttt_count(game, stats):
|
| 77 |
+
if not game.over or game.counted:
|
| 78 |
+
return
|
| 79 |
+
game.counted = True
|
| 80 |
+
key = {"agent": "agent_wins", "human": "agent_losses", "draw": "draws"}[game.result]
|
| 81 |
+
stats["games"] += 1
|
| 82 |
+
stats[key] += 1
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _ttt_outputs(game, resp, stats, explain, status=None):
|
| 86 |
+
return (*_ttt_cells(game), status or _ttt_status(game), _ttt_why(game, resp), _ttt_position(game, explain),
|
| 87 |
+
_ttt_counter(stats), game, resp, stats)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def ttt_new(side, stats, explain):
|
| 91 |
+
game, resp = tictactoe.new_game("X" if side.startswith("X") else "O")
|
| 92 |
+
return _ttt_outputs(game, resp, stats, explain)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def ttt_click(i, game, resp, stats, explain):
|
| 96 |
+
err = tictactoe.human_move(game, i)
|
| 97 |
+
if err:
|
| 98 |
+
return _ttt_outputs(game, resp, stats, explain, status=f"### {err}")
|
| 99 |
+
if not game.over:
|
| 100 |
+
resp = tictactoe.agent_move(game)
|
| 101 |
+
_ttt_count(game, stats)
|
| 102 |
+
return _ttt_outputs(game, resp, stats, explain)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def ttt_explain(game, explain):
|
| 106 |
+
return _ttt_position(game, explain)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ====================================================================================================================
|
| 110 |
+
# Ghost maze
|
| 111 |
+
# ====================================================================================================================
|
| 112 |
+
|
| 113 |
+
CAT_ON, SYS_ON = maze.build_system(True)
|
| 114 |
+
CAT_OFF, SYS_OFF = maze.build_system(False)
|
| 115 |
+
PAC_ANGLE = {"UP": 0, "RIGHT": 90, "DOWN": 180, "LEFT": 270, None: 90}
|
| 116 |
+
NATIVE_BENCH = {"with_check": {"win_rate": 0.59, "avg_score": 764, "avg_dots_pct": 0.94, "avg_deaths": 1.95},
|
| 117 |
+
"without_check": {"win_rate": 0.04, "avg_score": 531, "avg_dots_pct": 0.76, "avg_deaths": 2.96}}
|
| 118 |
+
BENCH_SIZES = {"20 games (about 8 s)": 20, "100 games (about 20 s)": 100}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _bench_rows(b, vetoes=True):
|
| 122 |
+
rows = []
|
| 123 |
+
for key, label in (("with_check", "hard safety check **ON**"), ("without_check", "hard safety check OFF")):
|
| 124 |
+
s = b[key]
|
| 125 |
+
rows.append(f"| {label} | **{s['win_rate'] * 100:.0f}%** | {s['avg_score']:.0f} | {s['avg_dots_pct'] * 100:.0f}% | "
|
| 126 |
+
f"{s['avg_deaths']:.2f} |" + (f" {s['avg_forced']:.1f} |" if vetoes else ""))
|
| 127 |
+
return "\n".join(rows)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def bench_native_markdown():
|
| 131 |
+
return ("#### Benchmark: 100 seeded games, same scoring rule, same ghosts\n"
|
| 132 |
+
"| agent | games cleared | avg score | dots eaten | lives lost (of 3) |\n|---|---|---|---|---|\n" +
|
| 133 |
+
_bench_rows(NATIVE_BENCH, vetoes=False) +
|
| 134 |
+
"\n\n<span class='sv-dim'>Measured with native Python (about 7 s on one CPU core). "
|
| 135 |
+
f"A game is cleared when Pac eats all {maze.TOTAL_DOTS} dots before losing 3 lives (limit {maze.MAX_TICKS} ticks). "
|
| 136 |
+
"The only difference between the rows is one hard check. Press the button to recompute it here, in your "
|
| 137 |
+
"browser.</span>")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def bench_run(size):
|
| 141 |
+
n = BENCH_SIZES.get(size, 20)
|
| 142 |
+
t0 = time.time()
|
| 143 |
+
b = maze.benchmark(n)
|
| 144 |
+
dt = time.time() - t0
|
| 145 |
+
return (f"#### Benchmark: {n} seeded games, computed in your browser in {dt:.1f} s\n"
|
| 146 |
+
"| agent | games cleared | avg score | dots eaten | lives lost (of 3) | vetoes per game |\n"
|
| 147 |
+
"|---|---|---|---|---|---|\n" + _bench_rows(b) +
|
| 148 |
+
"\n\n<span class='sv-dim'>Seeds 0 to " + str(n - 1) + ", the same as the native run. "
|
| 149 |
+
"The native numbers over 100 games: check ON 59% cleared, avg score 764; check OFF 4%, avg score 531.</span>")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def board_html(game, resp=None, show_danger=False):
|
| 153 |
+
ghosts = {}
|
| 154 |
+
for gh in game.ghosts:
|
| 155 |
+
ghosts.setdefault(gh.pos, gh)
|
| 156 |
+
danger = set()
|
| 157 |
+
if show_danger and not game.over:
|
| 158 |
+
# the agent's ghost_reach fact; computed here too, because with the check OFF the flow does not need it
|
| 159 |
+
reach = resp.values.get("ghost_reach") if resp is not None else None
|
| 160 |
+
if reach is None:
|
| 161 |
+
reach = {n for gh in game.ghosts for _, n in maze.open_neighbors(gh.pos)} | {gh.pos for gh in game.ghosts}
|
| 162 |
+
danger = set(map(tuple, reach))
|
| 163 |
+
cells = []
|
| 164 |
+
for r in range(maze.H):
|
| 165 |
+
for c in range(maze.W):
|
| 166 |
+
cell = (r, c)
|
| 167 |
+
if cell in maze.WALLS:
|
| 168 |
+
cells.append("<div class='mz c-wall'></div>")
|
| 169 |
+
continue
|
| 170 |
+
cls = "mz c-open" + (" c-danger" if cell in danger else "")
|
| 171 |
+
inner = ""
|
| 172 |
+
if cell == game.pac:
|
| 173 |
+
a = PAC_ANGLE[game.last_move] - 35
|
| 174 |
+
inner = (f"<div class='mz-pac' style='background:conic-gradient(from {a}deg, transparent 0deg 70deg, "
|
| 175 |
+
f"#ffd400 70deg 360deg)'></div>")
|
| 176 |
+
elif cell in ghosts:
|
| 177 |
+
gh = ghosts[cell]
|
| 178 |
+
inner = f"<div class='mz-ghost' style='background:{gh.color}' title='{gh.name}'>👀</div>"
|
| 179 |
+
elif cell in game.dots:
|
| 180 |
+
inner = "<div class='mz-dot'></div>"
|
| 181 |
+
cells.append(f"<div class='{cls}'>{inner}</div>")
|
| 182 |
+
banner = ""
|
| 183 |
+
if game.over:
|
| 184 |
+
banner = ("<div class='mz-banner mz-win'>CLEARED! 🎉</div>" if game.won
|
| 185 |
+
else "<div class='mz-banner mz-lose'>GAME OVER</div>")
|
| 186 |
+
return (f"<div class='mz-wrap'>{banner}<div class='mz-grid' style='grid-template-columns:repeat({maze.W}, 1fr)'>"
|
| 187 |
+
f"{''.join(cells)}</div></div>")
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def maze_stats(game):
|
| 191 |
+
eaten = maze.TOTAL_DOTS - len(game.dots)
|
| 192 |
+
return (f"**Score** {game.score} · **Lives** {'❤️' * game.lives or '—'} · "
|
| 193 |
+
f"**Dots** {eaten}/{maze.TOTAL_DOTS} · **Tick** {game.tick} · "
|
| 194 |
+
f"**hard-check vetoes** {game.forced} · seed {game.seed}")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _maze_why(resp, move, note, safety, who="solvi"):
|
| 198 |
+
if resp is None:
|
| 199 |
+
return "<div class='sv-card sv-dim'>Press Step or Play to let the solvi agent move. Each tick is explained here.</div>"
|
| 200 |
+
cat = CAT_ON if safety else CAT_OFF
|
| 201 |
+
qs = ["move", "fallback"] if safety else ["move"]
|
| 202 |
+
if who == "you":
|
| 203 |
+
title = f"You played {move}. solvi would have played {note}."
|
| 204 |
+
else:
|
| 205 |
+
title = "solvi: " + maze.describe(move, resp, note)
|
| 206 |
+
return why_html(resp, cat, qs, title=title)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _maze_outputs(game, resp, move, note, safety, show_danger, who="solvi"):
|
| 210 |
+
log = "\n".join(reversed(game.log[-40:]))
|
| 211 |
+
return board_html(game, resp, show_danger), maze_stats(game), log, _maze_why(resp, move, note, safety, who), game
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _event_suffix(ev):
|
| 215 |
+
return {"dot": "", "caught": " ✖ caught by a ghost!", "won": " ★ all dots eaten!", "over": " ✖ game over"}.get(ev, "")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def _agent_tick(game, safety):
|
| 219 |
+
system = SYS_ON if safety else SYS_OFF
|
| 220 |
+
mv, resp, note = maze.agent_decide(system, game, safety)
|
| 221 |
+
line = f"t{game.tick + 1:03d} " + maze.describe(mv, resp, note)
|
| 222 |
+
ev = maze.step(game, mv, note)
|
| 223 |
+
game.log.append(line + _event_suffix(ev))
|
| 224 |
+
return mv, resp, note
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def maze_step(game, safety, show_danger):
|
| 228 |
+
if game.over:
|
| 229 |
+
return _maze_outputs(game, None, None, None, safety, show_danger)
|
| 230 |
+
mv, resp, note = _agent_tick(game, safety)
|
| 231 |
+
return _maze_outputs(game, resp, mv, note, safety, show_danger)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
async def maze_play(game, safety, show_danger):
|
| 235 |
+
resp = mv = note = None
|
| 236 |
+
for _ in range(50):
|
| 237 |
+
if game.over:
|
| 238 |
+
break
|
| 239 |
+
mv, resp, note = _agent_tick(game, safety)
|
| 240 |
+
yield _maze_outputs(game, resp, mv, note, safety, show_danger)
|
| 241 |
+
await asyncio.sleep(0.07) # time.sleep would block the browser's Python worker
|
| 242 |
+
yield _maze_outputs(game, resp, mv, note, safety, show_danger)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def maze_reset(seed, safety, show_danger):
|
| 246 |
+
game = maze.new_game(int(seed or 0))
|
| 247 |
+
game.log.append(f"new game, seed {game.seed}: {maze.TOTAL_DOTS} dots, 3 ghosts, 3 lives")
|
| 248 |
+
return _maze_outputs(game, None, None, None, safety, show_danger)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def maze_human(move, game, safety, show_danger):
|
| 252 |
+
if game.over:
|
| 253 |
+
return _maze_outputs(game, None, None, None, safety, show_danger)
|
| 254 |
+
system = SYS_ON if safety else SYS_OFF
|
| 255 |
+
smv, resp, note = maze.agent_decide(system, game, safety)
|
| 256 |
+
v = resp.values
|
| 257 |
+
line = (f"t{game.tick + 1:03d} you {move} (ghost {v['ghost_dist'].get(move, 'wall')} away) — "
|
| 258 |
+
f"solvi would play {smv}" + (" (hard check vetoed its first choice)" if note == "forced" else ""))
|
| 259 |
+
ev = maze.step(game, move)
|
| 260 |
+
game.log.append(line + _event_suffix(ev))
|
| 261 |
+
return _maze_outputs(game, resp, move, smv, safety, show_danger, who="you")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def maze_redraw(game, safety, show_danger):
|
| 265 |
+
system = SYS_ON if safety else SYS_OFF
|
| 266 |
+
resp = None if game.over else system.ask(game.state(), ["move", "fallback"] if safety else ["move"])
|
| 267 |
+
return board_html(game, resp, show_danger)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ====================================================================================================================
|
| 271 |
+
# Card fusion lab
|
| 272 |
+
# ====================================================================================================================
|
| 273 |
+
|
| 274 |
+
CARD_COLORS = {"Whip": "#b5651d", "Magic Wand": "#7b61ff", "Frost Orb": "#2fa4e7", "Fire Staff": "#ff5a36",
|
| 275 |
+
"Vampire Fang": "#b3123f", "Thunder Hammer": "#e0b000", "Throwing Knives": "#6f7f8f", "Poison Cloud": "#3fa34d"}
|
| 276 |
+
BAR_MAX = {"damage": 40, "fire_rate": 3.4, "area": 6, "speed": 18, "pierce": 10}
|
| 277 |
+
BAR_LABEL = {"damage": "damage", "fire_rate": "fire rate /s", "area": "area", "speed": "speed", "pierce": "pierce"}
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def card_html(c, colors, tag="", marks=None, small=False):
|
| 281 |
+
marks = marks or {}
|
| 282 |
+
bars = []
|
| 283 |
+
for k in fusion.NUMERIC:
|
| 284 |
+
v = getattr(c, k)
|
| 285 |
+
pct = max(4, min(100, 100 * v / BAR_MAX[k]))
|
| 286 |
+
mark = f" <span class='fx-mark'>{html.escape(marks[k])}</span>" if k in marks else ""
|
| 287 |
+
txt = f"{v:.0f}" if k == "pierce" else f"{v:.2f}" if v < 10 else f"{v:.1f}"
|
| 288 |
+
bars.append(f"<div class='fx-row'><span class='fx-k'>{BAR_LABEL[k]}</span><div class='fx-bar'>"
|
| 289 |
+
f"<div style='width:{pct:.0f}%'></div></div><span class='fx-v'>{txt}{mark}</span></div>")
|
| 290 |
+
chips = []
|
| 291 |
+
for k, icon, label in (("slow", "❄️", "slow"), ("burn", "🔥", "burn"), ("lifesteal", "🩸", "lifesteal")):
|
| 292 |
+
v = getattr(c, k)
|
| 293 |
+
if v > 1e-9:
|
| 294 |
+
mark = f" <span class='fx-mark'>{html.escape(marks[k])}</span>" if k in marks else ""
|
| 295 |
+
chips.append(f"<span class='fx-chip'>{icon} {label} {'+' if k == 'burn' else ''}{v * 100:.0f}%{mark}</span>")
|
| 296 |
+
cd_mark = f" <span class='fx-mark'>{html.escape(marks['cooldown'])}</span>" if "cooldown" in marks else ""
|
| 297 |
+
chips.append(f"<span class='fx-chip'>⏱ cooldown {c.cooldown:.2f}s{cd_mark}</span>")
|
| 298 |
+
grad = f"linear-gradient(135deg, {colors[0]}, {colors[-1]})"
|
| 299 |
+
sig = f"<div class='fx-sig'>style trait: {html.escape(c.signature)}</div>" if c.signature else ""
|
| 300 |
+
return (f"<div class='fx-card{' fx-small' if small else ''}' style='--fx-grad:{grad}'>"
|
| 301 |
+
f"<div class='fx-head'><span class='fx-icon'>{c.icon}</span><div><div class='fx-name'>{html.escape(c.name)}</div>"
|
| 302 |
+
f"<div class='fx-shape'>{html.escape(c.shape)}{(' · ' + html.escape(tag)) if tag else ''}</div></div></div>"
|
| 303 |
+
f"{''.join(bars)}<div class='fx-chips'>{''.join(chips)}</div>{sig}"
|
| 304 |
+
f"<div class='fx-power'>⚡ power {fusion.card_power(c):.1f}</div></div>")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def gallery_html():
|
| 308 |
+
return "<div class='fx-gallery'>" + "".join(card_html(c, [CARD_COLORS[c.name]] * 2, small=True) for c in fusion.BASE) + "</div>"
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def do_fuse(a_name, b_name, law):
|
| 312 |
+
a, b = fusion.BY_NAME[a_name], fusion.BY_NAME[b_name]
|
| 313 |
+
raw = fusion.fuse(a, b, law)
|
| 314 |
+
final, resp, changes = fusion.balance(raw)
|
| 315 |
+
colors = [CARD_COLORS[a_name], CARD_COLORS[b_name]]
|
| 316 |
+
marks = {}
|
| 317 |
+
for q, key, txt in (("slow", "slow", "clamped"), ("lifesteal", "lifesteal", "clamped"), ("cooldown", "cooldown", "clamped")):
|
| 318 |
+
if resp[q].answer == "clamp":
|
| 319 |
+
marks[key] = txt
|
| 320 |
+
if resp["power"].answer != "in_band":
|
| 321 |
+
marks["damage"] = f"×{resp.values['damage_scale']:.2f}"
|
| 322 |
+
law_txt = ("BLEND — order does not matter: shape of the stronger parent, numbers averaged in log space, effects stack"
|
| 323 |
+
if law == "BLEND" else
|
| 324 |
+
f"DOMINANT — {a.name} in the style of {b.name}: shape and 75% of the numbers from {a.name}, "
|
| 325 |
+
f"{b.name}'s effects stack on top, plus its trait ({b.signature})")
|
| 326 |
+
cards = (f"<div class='fx-law'>{html.escape(law_txt)}</div><div class='fx-flow'>"
|
| 327 |
+
f"{card_html(a, [colors[0]] * 2, 'parent A', small=True)}<div class='fx-op'>{'+' if law == 'BLEND' else '◀'}</div>"
|
| 328 |
+
f"{card_html(b, [colors[1]] * 2, 'parent B', small=True)}<div class='fx-op'>=</div>"
|
| 329 |
+
f"{card_html(raw, colors, 'raw fusion', small=True)}<div class='fx-op'>⚖️</div>"
|
| 330 |
+
f"{card_html(final, colors, 'balanced by solvi', marks)}</div>")
|
| 331 |
+
ch = ("".join(f"<li>{html.escape(x)}</li>" for x in changes) if changes
|
| 332 |
+
else "<li>nothing to fix: every hard check passed and the power is inside the band</li>")
|
| 333 |
+
verdict = "ships unchanged ✅" if resp["ship_as_is"].answer == "yes" else "needed fixes 🔧"
|
| 334 |
+
report = (f"<div class='sv-card'><div class='sv-title'>Balance report: {html.escape(raw.name)} {verdict}</div>"
|
| 335 |
+
f"<ul class='fx-changes'>{ch}</ul></div>" +
|
| 336 |
+
why_html(resp, fusion.cat, [q.name for q in fusion.QUESTIONS], title="solvi answers (rule inputs, hard checks, facts)"))
|
| 337 |
+
return cards, report
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def surprise():
|
| 341 |
+
rng = random.Random()
|
| 342 |
+
a, b = rng.sample([c.name for c in fusion.BASE], 2)
|
| 343 |
+
law = rng.choice(["BLEND", "DOMINANT"])
|
| 344 |
+
return (a, b, law, *do_fuse(a, b, law))
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ====================================================================================================================
|
| 348 |
+
# Build your own bot
|
| 349 |
+
# ====================================================================================================================
|
| 350 |
+
|
| 351 |
+
_DEFAULT_RUNS: dict = {}
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _default_run(safety):
|
| 355 |
+
if safety not in _DEFAULT_RUNS:
|
| 356 |
+
_, system = maze.build_system(safety)
|
| 357 |
+
_DEFAULT_RUNS[safety] = maze.summarize([maze.run_game(system, s, safety) for s in range(sandbox.N_GAMES)])
|
| 358 |
+
return _DEFAULT_RUNS[safety]
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def run_user_bot(code, safety):
|
| 362 |
+
t0 = time.time()
|
| 363 |
+
res = sandbox.run_bot(code, safety=safety)
|
| 364 |
+
dt = time.time() - t0
|
| 365 |
+
if not res["ok"]:
|
| 366 |
+
return (f"<div class='sv-err'><b>Your bot did not run ({html.escape(res['kind'])}).</b>"
|
| 367 |
+
f"<pre>{html.escape(res['error'])}</pre></div>", "")
|
| 368 |
+
base = _default_run(safety)
|
| 369 |
+
you = res["summary"]
|
| 370 |
+
|
| 371 |
+
def row(name, s):
|
| 372 |
+
return (f"| {name} | **{s['win_rate'] * 100:.0f}%** | {s['avg_score']:.0f} | {s['avg_dots_pct'] * 100:.0f}% | "
|
| 373 |
+
f"{s['avg_deaths']:.2f} | {s['avg_forced']:.1f} |")
|
| 374 |
+
diff = you["avg_score"] - base["avg_score"]
|
| 375 |
+
verdict = ("🏆 Your bot beats the default!" if diff > 0 else "🤝 Same score as the default." if diff == 0
|
| 376 |
+
else "The default bot is still ahead. Tweak and try again.")
|
| 377 |
+
warn = ""
|
| 378 |
+
if res.get("abstains"):
|
| 379 |
+
warn = (f"<div class='sv-err'><b>Your function failed or returned an invalid move on {res['abstains']} ticks</b> "
|
| 380 |
+
f"(solvi abstained and Pac took the first legal move). First problem:"
|
| 381 |
+
f"<pre>{html.escape(str(res.get('first_error')))}</pre></div>")
|
| 382 |
+
md = (f"### {verdict}\n"
|
| 383 |
+
f"{sandbox.N_GAMES} seeded games, hard safety check {'ON' if safety else 'OFF'}, ran in your browser in {dt:.1f} s "
|
| 384 |
+
f"(limit {sandbox.DEADLINE_S:.0f} s)\n\n"
|
| 385 |
+
"| bot | games cleared | avg score | dots eaten | lives lost | vetoes per game |\n|---|---|---|---|---|---|\n"
|
| 386 |
+
f"{row('your bot', you)}\n{row('default bot', base)}\n\n"
|
| 387 |
+
"Per game (seed: score, ✓ = cleared): " +
|
| 388 |
+
" · ".join(f"{i}: {g['score']}{' ✓' if g['won'] else ''}" for i, g in enumerate(res["games"])))
|
| 389 |
+
return warn, md
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# ====================================================================================================================
|
| 393 |
+
# UI
|
| 394 |
+
# ====================================================================================================================
|
| 395 |
+
|
| 396 |
+
CSS = """
|
| 397 |
+
.gradio-container {max-width: 1180px !important; margin: auto;}
|
| 398 |
+
#hero h1 {font-size: 2.1rem; margin-bottom: 0;}
|
| 399 |
+
.sv-card {border: 1px solid var(--border-color-primary); border-radius: 12px; padding: 12px 14px; margin: 6px 0;
|
| 400 |
+
background: var(--block-background-fill); font-size: 0.92rem; overflow-x: auto;}
|
| 401 |
+
.sv-title {font-weight: 700; font-size: 1.02rem; margin-bottom: 8px;}
|
| 402 |
+
.sv-ans {margin: 4px 0 8px;}
|
| 403 |
+
.sv-why {font-family: var(--font-mono); font-size: 0.82rem; opacity: 0.85; margin-top: 2px; word-break: break-word;}
|
| 404 |
+
.sv-badge {display: inline-block; border-radius: 999px; padding: 1px 9px; font-size: 0.76rem; font-weight: 600; margin: 2px 2px;}
|
| 405 |
+
.sv-ok {background: #16a34a22; color: #16a34a; border: 1px solid #16a34a66;}
|
| 406 |
+
.sv-forced {background: #dc262622; color: #ef4444; border: 1px solid #dc262688;}
|
| 407 |
+
.sv-abstain {background: #ca8a0422; color: #ca8a04; border: 1px solid #ca8a0466;}
|
| 408 |
+
.sv-dim {opacity: 0.7;}
|
| 409 |
+
.sv-hc {margin: 6px 0;}
|
| 410 |
+
.sv-facts {width: 100%; border-collapse: collapse; font-size: 0.8rem; margin-top: 6px;}
|
| 411 |
+
.sv-facts td {border-top: 1px solid var(--border-color-primary); padding: 3px 6px; vertical-align: top;}
|
| 412 |
+
.sv-facts td:first-child {font-family: var(--font-mono); white-space: nowrap;}
|
| 413 |
+
.sv-err {border: 1px solid #dc2626aa; background: #dc262614; border-radius: 10px; padding: 10px 12px; margin: 6px 0;}
|
| 414 |
+
.sv-err pre {white-space: pre-wrap; font-size: 0.8rem; margin: 6px 0 0;}
|
| 415 |
+
.ttt-grid {max-width: 330px; gap: 6px !important;}
|
| 416 |
+
.ttt-grid button {min-height: 92px !important; font-size: 2.4rem !important; border-radius: 14px !important;}
|
| 417 |
+
.mz-wrap {position: relative; max-width: 440px; margin: 0 auto;}
|
| 418 |
+
.mz-grid {display: grid; gap: 0; background: #05061a; padding: 6px; border-radius: 12px; border: 2px solid #2233aa;}
|
| 419 |
+
.mz {aspect-ratio: 1; position: relative; display: flex; align-items: center; justify-content: center;}
|
| 420 |
+
.c-wall {background: #1b2a9e; border-radius: 4px; box-shadow: inset 0 0 0 2px #3b52ff55; margin: 1px;}
|
| 421 |
+
.c-danger {background: #ff2d2d33;}
|
| 422 |
+
.mz-dot {width: 22%; height: 22%; border-radius: 50%; background: #ffcfa0;}
|
| 423 |
+
.mz-pac {width: 82%; height: 82%; border-radius: 50%;}
|
| 424 |
+
.mz-ghost {width: 82%; height: 82%; border-radius: 45% 45% 12% 12%; display: flex; align-items: center; justify-content: center;
|
| 425 |
+
font-size: 0.7rem; line-height: 1;}
|
| 426 |
+
.mz-banner {position: absolute; top: 42%; left: 0; right: 0; text-align: center; font-weight: 800; font-size: 2rem; z-index: 2;
|
| 427 |
+
letter-spacing: 2px; text-shadow: 0 2px 8px #000;}
|
| 428 |
+
.mz-win {color: #7CFF7C;} .mz-lose {color: #ff5a5a;}
|
| 429 |
+
.arrows button {font-size: 1.4rem !important; min-width: 56px !important;}
|
| 430 |
+
.fx-gallery {display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px;}
|
| 431 |
+
.fx-flow {display: flex; flex-wrap: wrap; align-items: center; gap: 8px;}
|
| 432 |
+
.fx-op {font-size: 1.6rem; opacity: 0.8;}
|
| 433 |
+
.fx-law {font-size: 0.88rem; opacity: 0.85; margin-bottom: 8px;}
|
| 434 |
+
.fx-card {background: var(--fx-grad); color: #fff; border-radius: 16px; padding: 12px; width: 250px; box-sizing: border-box;
|
| 435 |
+
box-shadow: 0 6px 18px #0004; text-shadow: 0 1px 2px #0006;}
|
| 436 |
+
.fx-small {width: 185px; padding: 9px; font-size: 0.85rem;}
|
| 437 |
+
.fx-gallery .fx-small {width: auto;}
|
| 438 |
+
.fx-head {display: flex; gap: 8px; align-items: center; margin-bottom: 6px;}
|
| 439 |
+
.fx-icon {font-size: 2rem; background: #ffffff30; border-radius: 12px; padding: 2px 6px;}
|
| 440 |
+
.fx-small .fx-icon {font-size: 1.5rem;}
|
| 441 |
+
.fx-icon {white-space: nowrap;}
|
| 442 |
+
.fx-small .fx-row {grid-template-columns: 60px 1fr 44px;}
|
| 443 |
+
.fx-name {font-weight: 800; font-size: 1.08rem;}
|
| 444 |
+
.fx-shape {font-size: 0.72rem; opacity: 0.9; text-transform: uppercase; letter-spacing: 1px;}
|
| 445 |
+
.fx-row {display: grid; grid-template-columns: 74px 1fr 64px; align-items: center; gap: 6px; font-size: 0.76rem; margin: 2px 0;}
|
| 446 |
+
.fx-bar {height: 7px; background: #00000040; border-radius: 4px; overflow: hidden;}
|
| 447 |
+
.fx-bar div {height: 100%; background: #fff; border-radius: 4px;}
|
| 448 |
+
.fx-v {text-align: right; font-variant-numeric: tabular-nums;}
|
| 449 |
+
.fx-chips {display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px;}
|
| 450 |
+
.fx-chip {background: #00000038; border-radius: 999px; padding: 1px 7px; font-size: 0.72rem; color: #fff;}
|
| 451 |
+
.fx-card span, .fx-card div {color: inherit;}
|
| 452 |
+
.fx-card .fx-mark {background: #fff; color: #b00020; border-radius: 6px; padding: 0 4px; font-weight: 700; text-shadow: none; font-size: 0.68rem;}
|
| 453 |
+
.fx-power {margin-top: 7px; font-weight: 800;}
|
| 454 |
+
.fx-sig {font-size: 0.7rem; margin-top: 5px; opacity: 0.9;}
|
| 455 |
+
.fx-changes {margin: 0 0 4px 18px;}
|
| 456 |
+
@media (max-width: 640px) { .fx-card {width: 100%;} .fx-small {width: 46%;} .fx-op {display: none;} }
|
| 457 |
+
"""
|
| 458 |
+
|
| 459 |
+
INTRO = f"""
|
| 460 |
+
# 🕹️ solvi arcade
|
| 461 |
+
**Game agents made of small Python functions, a few rules and hard checks — strong, and every move is explained.**
|
| 462 |
+
No neural network, no GPU, **no server: the agents run right here, in your browser** (Python compiled to WebAssembly).
|
| 463 |
+
Each agent is a [solvi]({REPO}) catalog. For every move it shows the rule's inputs, the facts it computed this turn and
|
| 464 |
+
which hard check fired. Write your own decision task in the [solvi playground]({PLAYGROUND}).
|
| 465 |
+
"""
|
| 466 |
+
|
| 467 |
+
# Soft's default fonts are served from the Gradio server's /static folder, which Gradio-Lite does not have: use Google Fonts.
|
| 468 |
+
THEME = gr.themes.Soft(primary_hue="violet", secondary_hue="amber",
|
| 469 |
+
font=[gr.themes.GoogleFont("Montserrat"), "ui-sans-serif", "system-ui", "sans-serif"],
|
| 470 |
+
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "Consolas", "monospace"])
|
| 471 |
+
|
| 472 |
+
with gr.Blocks(title="solvi arcade", theme=THEME, css=CSS) as demo:
|
| 473 |
+
gr.Markdown(INTRO, elem_id="hero")
|
| 474 |
+
with gr.Tabs():
|
| 475 |
+
# ---------------------------------------------------------------------------------------------------- TTT
|
| 476 |
+
with gr.Tab("❌⭕ Tic-tac-toe"):
|
| 477 |
+
gr.Markdown("Play against a solvi agent whose whole brain is seven small functions (win, block, fork, forcing move ...) "
|
| 478 |
+
f"and one rule, from `{tictactoe.SOURCE}`. It answers every move with its reason. "
|
| 479 |
+
"It has never lost.")
|
| 480 |
+
ttt_game, ttt_resp = gr.State(), gr.State()
|
| 481 |
+
ttt_stats = gr.State({"games": 0, "agent_losses": 0, "agent_wins": 0, "draws": 0})
|
| 482 |
+
with gr.Row():
|
| 483 |
+
with gr.Column(scale=4, min_width=300):
|
| 484 |
+
with gr.Row():
|
| 485 |
+
ttt_side = gr.Radio(["X (you start)", "O (solvi starts)"], value="X (you start)", label="You play",
|
| 486 |
+
scale=3)
|
| 487 |
+
ttt_new_btn = gr.Button("New game", variant="primary", scale=1)
|
| 488 |
+
ttt_status = gr.Markdown("### Your move (❌)")
|
| 489 |
+
ttt_buttons = []
|
| 490 |
+
with gr.Column(elem_classes="ttt-grid"):
|
| 491 |
+
for _ in range(3):
|
| 492 |
+
with gr.Row(equal_height=True):
|
| 493 |
+
for _ in range(3):
|
| 494 |
+
ttt_buttons.append(gr.Button(EMPTY, min_width=80))
|
| 495 |
+
ttt_counter = gr.Markdown()
|
| 496 |
+
with gr.Column(scale=6):
|
| 497 |
+
ttt_why = gr.HTML()
|
| 498 |
+
ttt_explain_cb = gr.Checkbox(label="Explain the position (all computed facts for the side to move)")
|
| 499 |
+
ttt_pos = gr.HTML(visible=False)
|
| 500 |
+
ttt_outs = [*ttt_buttons, ttt_status, ttt_why, ttt_pos, ttt_counter, ttt_game, ttt_resp, ttt_stats]
|
| 501 |
+
ttt_new_btn.click(ttt_new, [ttt_side, ttt_stats, ttt_explain_cb], ttt_outs)
|
| 502 |
+
ttt_side.change(ttt_new, [ttt_side, ttt_stats, ttt_explain_cb], ttt_outs)
|
| 503 |
+
for i, b in enumerate(ttt_buttons):
|
| 504 |
+
b.click(lambda g, rs, s, e, i=i: ttt_click(i, g, rs, s, e), [ttt_game, ttt_resp, ttt_stats, ttt_explain_cb],
|
| 505 |
+
ttt_outs)
|
| 506 |
+
ttt_explain_cb.change(ttt_explain, [ttt_game, ttt_explain_cb], ttt_pos)
|
| 507 |
+
demo.load(ttt_new, [ttt_side, ttt_stats, ttt_explain_cb], ttt_outs)
|
| 508 |
+
|
| 509 |
+
# ---------------------------------------------------------------------------------------------------- maze
|
| 510 |
+
with gr.Tab("👻 Ghost maze"):
|
| 511 |
+
gr.Markdown(
|
| 512 |
+
"Pac is a solvi catalog: `legal_moves`, BFS `dot_dist` and `ghost_dist` per move, `ghost_reach`, `danger`, "
|
| 513 |
+
"`safe_moves`, one scoring function `greedy_move`, and a **hard check** `greedy_is_safe`: *never step onto a "
|
| 514 |
+
"cell a ghost can reach this tick when a safer move exists*. When it fires, the answer is forced to EVADE and "
|
| 515 |
+
"Pac takes the best safe move. Turn the check off and watch Pac die. Or take over with the arrows.")
|
| 516 |
+
mz_game = gr.State()
|
| 517 |
+
with gr.Row():
|
| 518 |
+
with gr.Column(scale=5, min_width=320):
|
| 519 |
+
mz_board = gr.HTML()
|
| 520 |
+
mz_stats = gr.Markdown()
|
| 521 |
+
with gr.Row():
|
| 522 |
+
mz_step = gr.Button("Step", variant="secondary")
|
| 523 |
+
mz_play = gr.Button("▶ Play 50 ticks", variant="primary")
|
| 524 |
+
mz_reset = gr.Button("Reset")
|
| 525 |
+
with gr.Row():
|
| 526 |
+
mz_safety = gr.Checkbox(value=True, label="hard safety check ON")
|
| 527 |
+
mz_danger = gr.Checkbox(value=False, label="show ghost reach (red)")
|
| 528 |
+
mz_seed = gr.Number(value=0, precision=0, label="seed", minimum=0, maximum=10**6)
|
| 529 |
+
gr.Markdown("**Take over** (one tick per press; solvi's choice is shown for comparison):")
|
| 530 |
+
with gr.Row(elem_classes="arrows"):
|
| 531 |
+
mz_left = gr.Button("←")
|
| 532 |
+
mz_up = gr.Button("↑")
|
| 533 |
+
mz_down = gr.Button("↓")
|
| 534 |
+
mz_right = gr.Button("→")
|
| 535 |
+
with gr.Column(scale=6):
|
| 536 |
+
mz_log = gr.Textbox(label="tick log (newest first)", lines=9, max_lines=9, interactive=False)
|
| 537 |
+
mz_why = gr.HTML()
|
| 538 |
+
with gr.Row(equal_height=True):
|
| 539 |
+
mz_bench_size = gr.Radio(list(BENCH_SIZES), value=next(iter(BENCH_SIZES)), label="benchmark size", scale=3)
|
| 540 |
+
mz_bench_btn = gr.Button("📊 Run the benchmark in your browser", scale=2)
|
| 541 |
+
mz_bench = gr.Markdown(bench_native_markdown())
|
| 542 |
+
mz_outs = [mz_board, mz_stats, mz_log, mz_why, mz_game]
|
| 543 |
+
mz_step.click(maze_step, [mz_game, mz_safety, mz_danger], mz_outs)
|
| 544 |
+
mz_play.click(maze_play, [mz_game, mz_safety, mz_danger], mz_outs)
|
| 545 |
+
mz_reset.click(maze_reset, [mz_seed, mz_safety, mz_danger], mz_outs)
|
| 546 |
+
for btn, mv in ((mz_left, "LEFT"), (mz_up, "UP"), (mz_down, "DOWN"), (mz_right, "RIGHT")):
|
| 547 |
+
btn.click(lambda g, s, d, mv=mv: maze_human(mv, g, s, d), [mz_game, mz_safety, mz_danger], mz_outs)
|
| 548 |
+
mz_danger.change(maze_redraw, [mz_game, mz_safety, mz_danger], mz_board)
|
| 549 |
+
demo.load(maze_reset, [mz_seed, mz_safety, mz_danger], mz_outs)
|
| 550 |
+
mz_bench_btn.click(lambda: "#### Benchmark: running in your browser …", None, mz_bench).then(
|
| 551 |
+
bench_run, mz_bench_size, mz_bench)
|
| 552 |
+
|
| 553 |
+
# ---------------------------------------------------------------------------------------------------- fusion
|
| 554 |
+
with gr.Tab("🃏 Card fusion lab"):
|
| 555 |
+
gr.Markdown(
|
| 556 |
+
"Fuse two weapon cards. The fused card then goes through a solvi balance catalog: it computes the power "
|
| 557 |
+
"(dps × targets × effects), **hard checks** slow ≤ 0.5, lifesteal ≤ 0.4, cooldown ≥ 0.3 s (a violation is "
|
| 558 |
+
"clamped and reported) and a soft check that power stays within [0.5 × median, 1.5 × max] of the base cards "
|
| 559 |
+
"(outside → damage is rescaled, and the report says by how much).")
|
| 560 |
+
with gr.Accordion("The 8 base cards", open=False):
|
| 561 |
+
gr.HTML(gallery_html())
|
| 562 |
+
names = [c.name for c in fusion.BASE]
|
| 563 |
+
with gr.Row():
|
| 564 |
+
fx_a = gr.Dropdown(names, value="Whip", label="Card A")
|
| 565 |
+
fx_b = gr.Dropdown(names, value="Frost Orb", label="Card B")
|
| 566 |
+
fx_law = gr.Radio(["BLEND", "DOMINANT"], value="BLEND", label="Fusion law",
|
| 567 |
+
info="BLEND: A+B = B+A · DOMINANT: A in the style of B")
|
| 568 |
+
with gr.Row():
|
| 569 |
+
fx_go = gr.Button("✨ Fuse", variant="primary")
|
| 570 |
+
fx_swap = gr.Button("⇄ Swap A and B")
|
| 571 |
+
fx_rand = gr.Button("🎲 Surprise me")
|
| 572 |
+
fx_cards = gr.HTML()
|
| 573 |
+
fx_report = gr.HTML()
|
| 574 |
+
gr.Markdown("Try **Whip ◀ Vampire Fang** (DOMINANT: lifesteal over the cap), **Magic Wand ◀ Throwing Knives** "
|
| 575 |
+
"(cooldown too short), **Thunder Hammer + Frost Orb** (BLEND: slow over the cap), or **Thunder Hammer ◀ "
|
| 576 |
+
"Fire Staff** (too strong, damage rescaled).")
|
| 577 |
+
fx_go.click(do_fuse, [fx_a, fx_b, fx_law], [fx_cards, fx_report])
|
| 578 |
+
fx_swap.click(lambda a, b: (b, a), [fx_a, fx_b], [fx_a, fx_b]).then(do_fuse, [fx_a, fx_b, fx_law], [fx_cards, fx_report])
|
| 579 |
+
fx_rand.click(surprise, None, [fx_a, fx_b, fx_law, fx_cards, fx_report])
|
| 580 |
+
demo.load(do_fuse, [fx_a, fx_b, fx_law], [fx_cards, fx_report])
|
| 581 |
+
|
| 582 |
+
# ---------------------------------------------------------------------------------------------------- bot
|
| 583 |
+
with gr.Tab("🛠️ Build your own bot"):
|
| 584 |
+
gr.Markdown(
|
| 585 |
+
"This is the maze agent's scoring function — the one piece of Pac's catalog you can rewrite. Change the "
|
| 586 |
+
f"weights or the logic, then run {sandbox.N_GAMES} seeded games against the default bot. solvi plans the flow "
|
| 587 |
+
"from your function's argument names, so you can use any fact listed in the comments. "
|
| 588 |
+
"Ideas: set `W_GHOST = 0` and turn the safety check off; use `danger` or `safe_moves` yourself; prefer moves "
|
| 589 |
+
"that keep more `legal_moves` open.")
|
| 590 |
+
with gr.Row():
|
| 591 |
+
with gr.Column(scale=6):
|
| 592 |
+
bot_code = gr.Code(value=maze.DEFAULT_BOT_CODE, language="python", lines=26, max_lines=40,
|
| 593 |
+
label="greedy_move (Python)", interactive=True)
|
| 594 |
+
with gr.Column(scale=6):
|
| 595 |
+
bot_safety = gr.Checkbox(value=True, label="keep the hard safety check greedy_is_safe")
|
| 596 |
+
with gr.Row():
|
| 597 |
+
bot_run = gr.Button(f"▶ Run {sandbox.N_GAMES} games", variant="primary")
|
| 598 |
+
bot_reset = gr.Button("Reset code")
|
| 599 |
+
bot_err = gr.HTML()
|
| 600 |
+
bot_out = gr.Markdown()
|
| 601 |
+
gr.Markdown("<span class='sv-dim'>Your code runs **in your own browser**, in this page's Python, so "
|
| 602 |
+
"nothing is sent to a server. A guard stops it when one call runs more than "
|
| 603 |
+
f"{sandbox.MAX_LINES_PER_CALL:,} lines (an infinite loop) or the whole run takes more than "
|
| 604 |
+
f"{sandbox.DEADLINE_S:.0f} s. A loop inside C code (e.g. `sum(itertools.count())`) "
|
| 605 |
+
"cannot be interrupted: then reload the page.</span>")
|
| 606 |
+
bot_run.click(run_user_bot, [bot_code, bot_safety], [bot_err, bot_out])
|
| 607 |
+
bot_reset.click(lambda: maze.DEFAULT_BOT_CODE, None, bot_code)
|
| 608 |
+
|
| 609 |
+
gr.Markdown(f"<span class='sv-dim'>Built with [solvi]({REPO}) (`pip install solvi`) — decision systems from a catalog "
|
| 610 |
+
"of Python functions and checks. No models, no server: this page runs Python in your browser with "
|
| 611 |
+
"Gradio-Lite and Pyodide. Every answer here is a solvi `Response`: `r[q].answer / .why / .status`, "
|
| 612 |
+
f"the planned `flow` and a hash-chained `trace`. More: the [solvi playground]({PLAYGROUND}).</span>")
|
| 613 |
+
|
| 614 |
+
demo.launch()
|
games/__init__.py
ADDED
|
File without changes
|
games/_ttt_catalog.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vendored copy of solvi/examples/05_tic_tac_toe.py (catalog part only), so the browser app needs nothing but solvi."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from solvi import Answer, Catalog, Question
|
| 5 |
+
|
| 6 |
+
LINES = [(0, 1, 2), (3, 4, 5), (6, 7, 8), (0, 3, 6), (1, 4, 7), (2, 5, 8), (0, 4, 8), (2, 4, 6)]
|
| 7 |
+
CELLS = [str(i) for i in range(9)]
|
| 8 |
+
|
| 9 |
+
cat = Catalog()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _wins(board, who):
|
| 13 |
+
return [c for c in range(9) if board[c] == "." and any(c in ln and sum(board[i] == who for i in ln) == 2 and
|
| 14 |
+
all(board[i] in (who, ".") for i in ln) for ln in LINES)]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _winner(board):
|
| 18 |
+
for a, b, c in LINES:
|
| 19 |
+
if board[a] != "." and board[a] == board[b] == board[c]:
|
| 20 |
+
return board[a]
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@cat.fn
|
| 25 |
+
def opponent(me):
|
| 26 |
+
return "O" if me == "X" else "X"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@cat.fn
|
| 30 |
+
def free_cells(board):
|
| 31 |
+
return [c for c in range(9) if board[c] == "."]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@cat.fn
|
| 35 |
+
def winning_moves(board, me):
|
| 36 |
+
"""cells where `me` completes a line right now"""
|
| 37 |
+
return _wins(board, me)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@cat.fn
|
| 41 |
+
def blocking_moves(board, opponent):
|
| 42 |
+
"""cells where the opponent would complete a line next move"""
|
| 43 |
+
return _wins(board, opponent)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@cat.fn
|
| 47 |
+
def fork_moves(board, me, free_cells):
|
| 48 |
+
"""cells that create two winning threats at once"""
|
| 49 |
+
return [c for c in free_cells if len(_wins(board[:c] + me + board[c + 1:], me)) >= 2]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@cat.fn
|
| 53 |
+
def opponent_forks(board, opponent, free_cells):
|
| 54 |
+
return [c for c in free_cells if len(_wins(board[:c] + opponent + board[c + 1:], opponent)) >= 2]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@cat.fn
|
| 58 |
+
def forcing_moves(board, me, opponent, free_cells):
|
| 59 |
+
"""cells that threaten a win so the opponent must block — at a cell that does not give the opponent a fork"""
|
| 60 |
+
out = []
|
| 61 |
+
for c in free_cells:
|
| 62 |
+
b = board[:c] + me + board[c + 1:]
|
| 63 |
+
threats = _wins(b, me)
|
| 64 |
+
if len(threats) == 1:
|
| 65 |
+
r = threats[0]
|
| 66 |
+
b2 = b[:r] + opponent + b[r + 1:]
|
| 67 |
+
if len(_wins(b2, opponent)) < 2:
|
| 68 |
+
out.append(c)
|
| 69 |
+
return out
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@cat.fn
|
| 73 |
+
def winner(board):
|
| 74 |
+
return _winner(board) or "-"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@cat.check(hard=True, then={"move": "none"})
|
| 78 |
+
def board_valid(board, me):
|
| 79 |
+
"""hard: 9 cells of X/O/., fair turn order, game not over"""
|
| 80 |
+
x, o = board.count("X"), board.count("O")
|
| 81 |
+
turn_ok = (x == o and me == "X") or (x == o + 1 and me == "O")
|
| 82 |
+
return len(board) == 9 and set(board) <= {"X", "O", "."} and turn_ok and _winner(board) is None and "." in board
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@cat.rule("move")
|
| 86 |
+
def pick_move(winning_moves, blocking_moves, fork_moves, opponent_forks, forcing_moves, free_cells):
|
| 87 |
+
for group in (winning_moves, blocking_moves, fork_moves):
|
| 88 |
+
if group:
|
| 89 |
+
return str(group[0])
|
| 90 |
+
if len(opponent_forks) == 1: # a single fork cell: take it
|
| 91 |
+
return str(opponent_forks[0])
|
| 92 |
+
if opponent_forks and forcing_moves: # several: force the opponent to answer a threat instead
|
| 93 |
+
return str(forcing_moves[0])
|
| 94 |
+
for c in (4, 0, 2, 6, 8, 1, 3, 5, 7): # center, corners, edges
|
| 95 |
+
if c in free_cells:
|
| 96 |
+
return str(c)
|
| 97 |
+
return "none"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@cat.rule("outcome")
|
| 101 |
+
def outcome(winner, free_cells, me):
|
| 102 |
+
if winner == "-":
|
| 103 |
+
return "draw" if not free_cells else "ongoing"
|
| 104 |
+
return "won" if winner == me else "lost"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
QUESTIONS = [Question("move", "Which cell to play?", Answer.choice(CELLS + ["none"]), checkpoints=["board_valid"]),
|
| 108 |
+
Question("outcome", "State of the game for me?", Answer.choice(["ongoing", "won", "lost", "draw"]))]
|
games/explain.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared helpers: turn a solvi Response into a readable "why" panel (rule inputs, facts computed this turn, hard checks)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import html
|
| 5 |
+
|
| 6 |
+
from solvi.runtime import MISSING
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _short(v, limit=90):
|
| 10 |
+
s = repr(v)
|
| 11 |
+
return s if len(s) <= limit else s[: limit - 1] + "…"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def hard_checks(resp, catalog):
|
| 15 |
+
"""[(name, passed)] for every hard check that ran in this response."""
|
| 16 |
+
out = []
|
| 17 |
+
for r in resp.trace.records:
|
| 18 |
+
p = catalog.parts.get(r.name)
|
| 19 |
+
if p is not None and p.kind == "check" and p.hard and r.value is not MISSING:
|
| 20 |
+
out.append((r.name, bool(r.value)))
|
| 21 |
+
return out
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def facts(resp):
|
| 25 |
+
"""[(name, value, kind)] for every fact computed in this response (rules excluded)."""
|
| 26 |
+
return [(r.name, r.value, r.kind) for r in resp.trace.records if r.kind != "rule" and r.value is not MISSING]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def why_html(resp, catalog, questions, show_facts=True, title=None):
|
| 30 |
+
"""An HTML card: answers with status/why, hard checks, and (optionally) all facts computed this turn."""
|
| 31 |
+
rows = []
|
| 32 |
+
for q in questions:
|
| 33 |
+
r = resp[q]
|
| 34 |
+
badge = {"ok": "ok", "forced": "forced by a hard check", "abstain": "abstained"}[r.status]
|
| 35 |
+
cls = {"ok": "sv-ok", "forced": "sv-forced", "abstain": "sv-abstain"}[r.status]
|
| 36 |
+
rows.append(f"<div class='sv-ans'><b>{html.escape(q)}</b> = <code>{html.escape(str(r.answer))}</code> "
|
| 37 |
+
f"<span class='sv-badge {cls}'>{badge}</span> <span class='sv-dim'>confidence {r.confidence:.2f}</span>"
|
| 38 |
+
f"<div class='sv-why'>why: {html.escape(r.why)}</div></div>")
|
| 39 |
+
hc = hard_checks(resp, catalog)
|
| 40 |
+
if hc:
|
| 41 |
+
items = " ".join(f"<span class='sv-badge {'sv-ok' if ok else 'sv-forced'}'>{html.escape(n)}: "
|
| 42 |
+
f"{'passed' if ok else 'FIRED'}</span>" for n, ok in hc)
|
| 43 |
+
rows.append(f"<div class='sv-hc'>hard checks: {items}</div>")
|
| 44 |
+
else:
|
| 45 |
+
rows.append("<div class='sv-hc sv-dim'>hard checks: none in this flow</div>")
|
| 46 |
+
if show_facts:
|
| 47 |
+
fr = "".join(f"<tr><td>{html.escape(n)}</td><td class='sv-dim'>{k}</td><td><code>{html.escape(_short(v))}</code></td></tr>"
|
| 48 |
+
for n, v, k in facts(resp))
|
| 49 |
+
rows.append(f"<details open><summary>facts computed this turn ({len(facts(resp))} steps, "
|
| 50 |
+
f"{resp.ms:.2f} ms)</summary><table class='sv-facts'>{fr}</table></details>")
|
| 51 |
+
head = f"<div class='sv-title'>{html.escape(title)}</div>" if title else ""
|
| 52 |
+
return f"<div class='sv-card'>{head}{''.join(rows)}</div>"
|
games/fusion.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Card fusion lab: fuse two weapon cards with a fusion law, then let a solvi catalog balance the result.
|
| 2 |
+
|
| 3 |
+
Laws
|
| 4 |
+
BLEND order does not matter: shape of the stronger parent, numbers averaged in log space (geometric mean),
|
| 5 |
+
effects stack (added).
|
| 6 |
+
DOMINANT "A in the style of B", order matters: shape of A, numbers 75% A / 25% B in log space, B's effects stack
|
| 7 |
+
onto A's, then B's signature trait is applied.
|
| 8 |
+
|
| 9 |
+
Balance (solvi): hard checks slow <= 0.5, lifesteal <= 0.4, cooldown >= 0.3 s (a violation is clamped and reported);
|
| 10 |
+
soft check: power within [0.5 x median, 1.5 x max] of the base cards (outside -> damage is scaled and the factor reported)."""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import math
|
| 14 |
+
import statistics
|
| 15 |
+
from dataclasses import asdict, dataclass, replace
|
| 16 |
+
|
| 17 |
+
from solvi import Answer, Catalog, Question, System
|
| 18 |
+
|
| 19 |
+
SLOW_CAP, LIFESTEAL_CAP, COOLDOWN_MIN = 0.5, 0.4, 0.3
|
| 20 |
+
NUMERIC = ["damage", "fire_rate", "area", "speed", "pierce"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class Card:
|
| 25 |
+
name: str
|
| 26 |
+
prefix: str # used when this card gives its style to a fusion ("Frost ...")
|
| 27 |
+
noun: str # used when this card gives its shape ("... Whip")
|
| 28 |
+
icon: str
|
| 29 |
+
shape: str
|
| 30 |
+
damage: float
|
| 31 |
+
fire_rate: float # shots per second (cooldown = 1 / fire_rate)
|
| 32 |
+
area: float
|
| 33 |
+
speed: float
|
| 34 |
+
pierce: float
|
| 35 |
+
slow: float = 0.0
|
| 36 |
+
burn: float = 0.0 # extra damage over time, as a fraction of hit damage
|
| 37 |
+
lifesteal: float = 0.0
|
| 38 |
+
signature: str = "" # trait applied when this card is the style (B) in DOMINANT
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def cooldown(self):
|
| 42 |
+
return 1.0 / self.fire_rate
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
SIGNATURES = { # name -> (description, function Card -> Card)
|
| 46 |
+
"Whip": ("reach: area x1.3", lambda c: replace(c, area=c.area * 1.3)),
|
| 47 |
+
"Magic Wand": ("homing: speed x1.5, pierce +1", lambda c: replace(c, speed=c.speed * 1.5, pierce=c.pierce + 1)),
|
| 48 |
+
"Frost Orb": ("chill: slow +0.15", lambda c: replace(c, slow=c.slow + 0.15)),
|
| 49 |
+
"Fire Staff": ("ignite: burn +0.4", lambda c: replace(c, burn=c.burn + 0.4)),
|
| 50 |
+
"Vampire Fang": ("thirst: lifesteal +0.15", lambda c: replace(c, lifesteal=c.lifesteal + 0.15)),
|
| 51 |
+
"Thunder Hammer": ("weight: damage x1.4, fire rate x0.8", lambda c: replace(c, damage=c.damage * 1.4, fire_rate=c.fire_rate * 0.8)),
|
| 52 |
+
"Throwing Knives": ("flurry: fire rate x1.6", lambda c: replace(c, fire_rate=c.fire_rate * 1.6)),
|
| 53 |
+
"Poison Cloud": ("spread: area x1.5, damage x0.7", lambda c: replace(c, area=c.area * 1.5, damage=c.damage * 0.7)),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
BASE = [
|
| 57 |
+
Card("Whip", "Lashing", "Whip", "🪢", "arc", 12, 1.2, 2.0, 4, 5, lifesteal=0.2),
|
| 58 |
+
Card("Magic Wand", "Arcane", "Wand", "🪄", "bolt", 10, 2.0, 0.5, 9, 1),
|
| 59 |
+
Card("Frost Orb", "Frost", "Orb", "🔮", "orb", 8, 0.8, 2.5, 3, 3, slow=0.35),
|
| 60 |
+
Card("Fire Staff", "Blazing", "Staff", "🔥", "fireball", 14, 1.0, 1.5, 5, 1, burn=0.6),
|
| 61 |
+
Card("Vampire Fang", "Vampiric", "Fang", "🦇", "bite", 12, 1.5, 0.6, 7, 1, lifesteal=0.25),
|
| 62 |
+
Card("Thunder Hammer", "Thunder", "Hammer", "🔨", "shockwave", 30, 0.5, 3.0, 2, 8, slow=0.2),
|
| 63 |
+
Card("Throwing Knives", "Swift", "Knives", "🗡️", "volley", 6, 3.0, 0.3, 12, 2),
|
| 64 |
+
Card("Poison Cloud", "Toxic", "Cloud", "☠️", "cloud", 3, 1.0, 4.0, 1, 10, burn=1.0),
|
| 65 |
+
]
|
| 66 |
+
BASE = [replace(c, signature=SIGNATURES[c.name][0]) for c in BASE]
|
| 67 |
+
BY_NAME = {c.name: c for c in BASE}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def targets_of(pierce, area):
|
| 71 |
+
"""expected enemies hit per shot: area finds them, pierce caps them"""
|
| 72 |
+
return min(pierce, 1 + area)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def power_of(damage, fire_rate, pierce, area, burn, slow, lifesteal):
|
| 76 |
+
return damage * fire_rate * targets_of(pierce, area) * (1 + burn) * (1 + slow) * (1 + lifesteal)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def card_power(c: Card):
|
| 80 |
+
return power_of(c.damage, c.fire_rate, c.pierce, c.area, c.burn, c.slow, c.lifesteal)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
BASE_POWERS = tuple(round(card_power(c), 3) for c in BASE)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _lmix(a, b, wa):
|
| 87 |
+
return math.exp(wa * math.log(a) + (1 - wa) * math.log(b))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def fuse(a: Card, b: Card, law: str) -> Card:
|
| 91 |
+
"""Raw fusion (before balancing)."""
|
| 92 |
+
law = law.upper()
|
| 93 |
+
if law == "BLEND":
|
| 94 |
+
pa, pb = card_power(a), card_power(b)
|
| 95 |
+
strong, weak = (a, b) if (pa, a.name) >= (pb, b.name) else (b, a)
|
| 96 |
+
nums = {k: _lmix(getattr(a, k), getattr(b, k), 0.5) for k in NUMERIC}
|
| 97 |
+
name = f"Twin {strong.noun}" if a == b else f"{weak.prefix} {strong.noun}"
|
| 98 |
+
shape_from, style_from = strong, weak
|
| 99 |
+
elif law == "DOMINANT":
|
| 100 |
+
nums = {k: _lmix(getattr(a, k), getattr(b, k), 0.75) for k in NUMERIC}
|
| 101 |
+
name = f"Mighty {a.noun}" if a == b else f"{b.prefix} {a.noun}"
|
| 102 |
+
shape_from, style_from = a, b
|
| 103 |
+
else:
|
| 104 |
+
raise ValueError(f"unknown law {law}")
|
| 105 |
+
nums["pierce"] = float(max(1, round(nums["pierce"])))
|
| 106 |
+
c = Card(name, style_from.prefix, shape_from.noun, shape_from.icon + (style_from.icon if style_from != shape_from else ""),
|
| 107 |
+
shape_from.shape, slow=a.slow + b.slow if a != b else a.slow, burn=a.burn + b.burn if a != b else a.burn,
|
| 108 |
+
lifesteal=a.lifesteal + b.lifesteal if a != b else a.lifesteal, **nums)
|
| 109 |
+
if law == "DOMINANT":
|
| 110 |
+
c = SIGNATURES[b.name][1](c)
|
| 111 |
+
c = replace(c, pierce=float(max(1, round(c.pierce))))
|
| 112 |
+
return replace(c, signature="")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 116 |
+
# The balance catalog
|
| 117 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 118 |
+
|
| 119 |
+
cat = Catalog()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@cat.fn
|
| 123 |
+
def cooldown(fire_rate):
|
| 124 |
+
"""seconds between shots"""
|
| 125 |
+
return round(1.0 / fire_rate, 4)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@cat.fn
|
| 129 |
+
def targets(pierce, area):
|
| 130 |
+
return round(targets_of(pierce, area), 4)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@cat.fn
|
| 134 |
+
def raw_dps(damage, fire_rate, targets):
|
| 135 |
+
"""hit damage per second over all enemies hit, before effects"""
|
| 136 |
+
return round(damage * fire_rate * targets, 3)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@cat.fn
|
| 140 |
+
def slow_final(slow):
|
| 141 |
+
return min(slow, SLOW_CAP)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@cat.fn
|
| 145 |
+
def lifesteal_final(lifesteal):
|
| 146 |
+
return min(lifesteal, LIFESTEAL_CAP)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@cat.fn
|
| 150 |
+
def fire_rate_final(fire_rate):
|
| 151 |
+
return min(fire_rate, 1.0 / COOLDOWN_MIN)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@cat.fn
|
| 155 |
+
def power_after_clamps(raw_dps, fire_rate, fire_rate_final, burn, slow_final, lifesteal_final):
|
| 156 |
+
"""effective power = raw dps x (1 + burn) x (1 + slow) x (1 + lifesteal), with the hard limits applied"""
|
| 157 |
+
return round(raw_dps * fire_rate_final / fire_rate * (1 + burn) * (1 + slow_final) * (1 + lifesteal_final), 3)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@cat.fn
|
| 161 |
+
def band_lo(base_powers):
|
| 162 |
+
return round(0.5 * statistics.median(base_powers), 3)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
@cat.fn
|
| 166 |
+
def band_hi(base_powers):
|
| 167 |
+
return round(1.5 * max(base_powers), 3)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@cat.check
|
| 171 |
+
def power_in_band(power_after_clamps, band_lo, band_hi):
|
| 172 |
+
"""soft: power within [0.5 x median, 1.5 x max] of the base cards"""
|
| 173 |
+
return band_lo <= power_after_clamps <= band_hi
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@cat.fn
|
| 177 |
+
def damage_scale(power_after_clamps, band_lo, band_hi):
|
| 178 |
+
"""factor on damage that brings power back to the nearest edge of the band (1.0 when inside)"""
|
| 179 |
+
if power_after_clamps > band_hi:
|
| 180 |
+
return round(band_hi / power_after_clamps, 4)
|
| 181 |
+
if power_after_clamps < band_lo:
|
| 182 |
+
return round(band_lo / power_after_clamps, 4)
|
| 183 |
+
return 1.0
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@cat.check(hard=True, then={"slow": "clamp", "ship_as_is": "no"})
|
| 187 |
+
def slow_within_cap(slow):
|
| 188 |
+
"""hard: slow <= 0.5"""
|
| 189 |
+
return slow <= SLOW_CAP + 1e-9
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@cat.check(hard=True, then={"lifesteal": "clamp", "ship_as_is": "no"})
|
| 193 |
+
def lifesteal_within_cap(lifesteal):
|
| 194 |
+
"""hard: lifesteal <= 0.4"""
|
| 195 |
+
return lifesteal <= LIFESTEAL_CAP + 1e-9
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@cat.check(hard=True, then={"cooldown": "clamp", "ship_as_is": "no"})
|
| 199 |
+
def cooldown_ok(cooldown):
|
| 200 |
+
"""hard: cooldown >= 0.3 s"""
|
| 201 |
+
return cooldown >= COOLDOWN_MIN - 1e-9
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@cat.rule("slow")
|
| 205 |
+
def slow_rule(slow):
|
| 206 |
+
return "keep"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@cat.rule("lifesteal")
|
| 210 |
+
def lifesteal_rule(lifesteal):
|
| 211 |
+
return "keep"
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@cat.rule("cooldown")
|
| 215 |
+
def cooldown_rule(cooldown):
|
| 216 |
+
return "keep"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@cat.rule("power")
|
| 220 |
+
def power_rule(power_in_band, power_after_clamps, band_lo, band_hi, damage_scale):
|
| 221 |
+
if power_in_band:
|
| 222 |
+
return "in_band"
|
| 223 |
+
return "scale_down" if power_after_clamps > band_hi else "scale_up"
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@cat.rule("ship_as_is")
|
| 227 |
+
def ship_rule(power_in_band):
|
| 228 |
+
return power_in_band
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
QUESTIONS = [
|
| 232 |
+
Question("slow", "Keep the slow as fused?", Answer.choice(["keep", "clamp"]), checkpoints=["slow_within_cap"]),
|
| 233 |
+
Question("lifesteal", "Keep the lifesteal as fused?", Answer.choice(["keep", "clamp"]), checkpoints=["lifesteal_within_cap"]),
|
| 234 |
+
Question("cooldown", "Keep the cooldown as fused?", Answer.choice(["keep", "clamp"]), checkpoints=["cooldown_ok"]),
|
| 235 |
+
Question("power", "Is the power in the base cards' band?", Answer.choice(["in_band", "scale_down", "scale_up"])),
|
| 236 |
+
Question("ship_as_is", "Can the fused card ship unchanged?", Answer.yes_no(),
|
| 237 |
+
checkpoints=["slow_within_cap", "lifesteal_within_cap", "cooldown_ok"]),
|
| 238 |
+
]
|
| 239 |
+
system = System(cat, QUESTIONS)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def card_state(c: Card):
|
| 243 |
+
return {"damage": c.damage, "fire_rate": c.fire_rate, "area": c.area, "speed": c.speed, "pierce": c.pierce,
|
| 244 |
+
"slow": c.slow, "burn": c.burn, "lifesteal": c.lifesteal, "base_powers": BASE_POWERS}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def balance(c: Card):
|
| 248 |
+
"""Run the balance catalog. Returns (final card, response, list of human-readable changes)."""
|
| 249 |
+
resp = system.ask(card_state(c))
|
| 250 |
+
v = resp.values
|
| 251 |
+
changes = []
|
| 252 |
+
out = c
|
| 253 |
+
if resp["slow"].answer == "clamp":
|
| 254 |
+
changes.append(f"slow {c.slow:.2f} → {SLOW_CAP:.2f} (hard check slow_within_cap)")
|
| 255 |
+
out = replace(out, slow=v["slow_final"])
|
| 256 |
+
if resp["lifesteal"].answer == "clamp":
|
| 257 |
+
changes.append(f"lifesteal {c.lifesteal:.2f} → {LIFESTEAL_CAP:.2f} (hard check lifesteal_within_cap)")
|
| 258 |
+
out = replace(out, lifesteal=v["lifesteal_final"])
|
| 259 |
+
if resp["cooldown"].answer == "clamp":
|
| 260 |
+
changes.append(f"cooldown {c.cooldown:.2f}s → {COOLDOWN_MIN:.2f}s (hard check cooldown_ok)")
|
| 261 |
+
out = replace(out, fire_rate=v["fire_rate_final"])
|
| 262 |
+
p = resp["power"].answer
|
| 263 |
+
if p in ("scale_down", "scale_up"):
|
| 264 |
+
s = v["damage_scale"]
|
| 265 |
+
changes.append(f"power {v['power_after_clamps']:.1f} is {'above' if p == 'scale_down' else 'below'} the band "
|
| 266 |
+
f"[{v['band_lo']:.1f}, {v['band_hi']:.1f}] → damage x{s:.2f} ({(s - 1) * 100:+.0f}%)")
|
| 267 |
+
out = replace(out, damage=c.damage * s)
|
| 268 |
+
return out, resp, changes
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def card_dict(c: Card):
|
| 272 |
+
d = asdict(c)
|
| 273 |
+
d["cooldown"] = c.cooldown
|
| 274 |
+
d["power"] = card_power(c)
|
| 275 |
+
return d
|
games/maze.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ghost maze: a small Pac-Man-like game. The Pac agent is a solvi catalog of small functions over the board
|
| 2 |
+
(legal moves, BFS distance to the nearest dot and to the nearest ghost per move, danger per move ...), one scoring rule,
|
| 3 |
+
and a HARD check: never step onto a cell a ghost can reach this tick when a safer move exists.
|
| 4 |
+
|
| 5 |
+
Pure logic, no UI: `new_game`, `agent_decide`, `step`, `run_game`, `benchmark`."""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
import random
|
| 10 |
+
from collections import deque
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
|
| 13 |
+
from solvi import Answer, Catalog, Question, System
|
| 14 |
+
|
| 15 |
+
LAYOUT = [
|
| 16 |
+
"#############",
|
| 17 |
+
"#.....#.....#",
|
| 18 |
+
"#.###.#.###.#",
|
| 19 |
+
"#...........#",
|
| 20 |
+
"#.#.##.##.#.#",
|
| 21 |
+
"#.#...G...#.#",
|
| 22 |
+
"#.#.##.##.#.#",
|
| 23 |
+
"#...........#",
|
| 24 |
+
"#.###.#.###.#",
|
| 25 |
+
"#.....P.....#",
|
| 26 |
+
"#############",
|
| 27 |
+
]
|
| 28 |
+
H, W = len(LAYOUT), len(LAYOUT[0])
|
| 29 |
+
WALLS = frozenset((r, c) for r in range(H) for c in range(W) if LAYOUT[r][c] == "#")
|
| 30 |
+
PAC_START = next((r, c) for r in range(H) for c in range(W) if LAYOUT[r][c] == "P")
|
| 31 |
+
GHOST_HOME = next((r, c) for r in range(H) for c in range(W) if LAYOUT[r][c] == "G")
|
| 32 |
+
ALL_DOTS = tuple(sorted((r, c) for r in range(H) for c in range(W) if LAYOUT[r][c] in ".G"))
|
| 33 |
+
TOTAL_DOTS = len(ALL_DOTS) # Pac's start cell has no dot
|
| 34 |
+
|
| 35 |
+
DIRS = {"UP": (-1, 0), "LEFT": (0, -1), "DOWN": (1, 0), "RIGHT": (0, 1)}
|
| 36 |
+
REVERSE = {"UP": "DOWN", "DOWN": "UP", "LEFT": "RIGHT", "RIGHT": "LEFT", None: None}
|
| 37 |
+
MOVES = list(DIRS)
|
| 38 |
+
|
| 39 |
+
# ghost tuning (chosen so the game is fair but not trivial; see benchmark numbers in the README)
|
| 40 |
+
GHOSTS = [ # name, color, scatter corner, release tick
|
| 41 |
+
("Blinky", "#ff4d4d", (1, 11), 0),
|
| 42 |
+
("Pinky", "#ff8fd8", (1, 1), 8),
|
| 43 |
+
("Clyde", "#ffb347", (9, 1), 16),
|
| 44 |
+
]
|
| 45 |
+
SCATTER, CYCLE = 7, 34 # first SCATTER ticks of every CYCLE ticks: ghosts go to their corners
|
| 46 |
+
GHOST_NOISE = 0.12 # chance a ghost takes a random turn at a junction
|
| 47 |
+
GHOST_SPEED = 0.9 # chance a ghost moves on a given tick
|
| 48 |
+
MAX_TICKS = 400
|
| 49 |
+
LIVES = 3
|
| 50 |
+
DOT_POINTS, CLEAR_BONUS = 10, 200
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def step_cell(cell, move):
|
| 54 |
+
dr, dc = DIRS[move]
|
| 55 |
+
return (cell[0] + dr, cell[1] + dc)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def open_neighbors(cell):
|
| 59 |
+
return [(m, step_cell(cell, m)) for m in MOVES if step_cell(cell, m) not in WALLS]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def bfs_dist(start, targets):
|
| 63 |
+
"""Shortest path length (through open cells) from start to the nearest cell in targets; 99 if none."""
|
| 64 |
+
targets = set(targets)
|
| 65 |
+
if not targets:
|
| 66 |
+
return 99
|
| 67 |
+
if start in targets:
|
| 68 |
+
return 0
|
| 69 |
+
seen, q = {start}, deque([(start, 0)])
|
| 70 |
+
while q:
|
| 71 |
+
cell, d = q.popleft()
|
| 72 |
+
for _, n in open_neighbors(cell):
|
| 73 |
+
if n not in seen:
|
| 74 |
+
if n in targets:
|
| 75 |
+
return d + 1
|
| 76 |
+
seen.add(n)
|
| 77 |
+
q.append((n, d + 1))
|
| 78 |
+
return 99
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 82 |
+
# The Pac agent as a solvi catalog
|
| 83 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
DEFAULT_BOT_CODE = '''\
|
| 86 |
+
# The Pac agent's scoring rule. It is an ordinary solvi function:
|
| 87 |
+
# its argument names are facts the catalog computes each tick, its name is the fact it sets.
|
| 88 |
+
# Facts you can use: pac, ghosts, dots, last_move, legal_moves, next_cells, dot_dist,
|
| 89 |
+
# ghost_dist, ghost_reach, danger, safe_moves, dots_left
|
| 90 |
+
# dot_dist / ghost_dist / danger are dicts {move: value}, moves are "UP", "DOWN", "LEFT", "RIGHT".
|
| 91 |
+
|
| 92 |
+
W_DOT = 1.0 # pull toward the nearest dot
|
| 93 |
+
W_GHOST = 0.6 # push away from the nearest ghost ...
|
| 94 |
+
GHOST_CAP = 5 # ... but only while it is closer than this
|
| 95 |
+
W_REVERSE = 0.5 # penalty for turning back (stops dithering)
|
| 96 |
+
|
| 97 |
+
def greedy_move(legal_moves, dot_dist, ghost_dist, last_move):
|
| 98 |
+
back = {"UP": "DOWN", "DOWN": "UP", "LEFT": "RIGHT", "RIGHT": "LEFT"}.get(last_move)
|
| 99 |
+
|
| 100 |
+
def score(m):
|
| 101 |
+
return (-W_DOT * dot_dist[m]
|
| 102 |
+
+ W_GHOST * min(ghost_dist[m], GHOST_CAP)
|
| 103 |
+
- W_REVERSE * (m == back))
|
| 104 |
+
|
| 105 |
+
return max(legal_moves, key=score)
|
| 106 |
+
'''
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def load_bot(code: str = DEFAULT_BOT_CODE):
|
| 110 |
+
"""exec bot code and return its greedy_move function (raises on errors)."""
|
| 111 |
+
ns: dict = {}
|
| 112 |
+
exec(compile(code, "<bot>", "exec"), ns) # noqa: S102 - user code runs in the visitor's own browser, under games/sandbox.py's guard
|
| 113 |
+
f = ns.get("greedy_move")
|
| 114 |
+
if not callable(f):
|
| 115 |
+
raise ValueError("the code must define a function named greedy_move")
|
| 116 |
+
return f
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def build_catalog(safety: bool = True, greedy=None):
|
| 120 |
+
cat = Catalog()
|
| 121 |
+
|
| 122 |
+
@cat.fn
|
| 123 |
+
def legal_moves(pac):
|
| 124 |
+
"""moves that do not bump into a wall"""
|
| 125 |
+
return [m for m, _ in open_neighbors(pac)]
|
| 126 |
+
|
| 127 |
+
@cat.fn
|
| 128 |
+
def next_cells(pac, legal_moves):
|
| 129 |
+
return {m: step_cell(pac, m) for m in legal_moves}
|
| 130 |
+
|
| 131 |
+
@cat.fn
|
| 132 |
+
def ghost_reach(ghosts):
|
| 133 |
+
"""cells a ghost occupies now or can step into this tick"""
|
| 134 |
+
cells = set(ghosts)
|
| 135 |
+
for g in ghosts:
|
| 136 |
+
cells.update(n for _, n in open_neighbors(g))
|
| 137 |
+
return sorted(cells)
|
| 138 |
+
|
| 139 |
+
@cat.fn
|
| 140 |
+
def dot_dist(dots, next_cells):
|
| 141 |
+
"""per move: BFS distance from the next cell to the nearest dot (0 = eats a dot)"""
|
| 142 |
+
return {m: bfs_dist(c, dots) for m, c in next_cells.items()}
|
| 143 |
+
|
| 144 |
+
@cat.fn
|
| 145 |
+
def ghost_dist(ghosts, next_cells):
|
| 146 |
+
"""per move: BFS distance from the next cell to the nearest ghost"""
|
| 147 |
+
return {m: bfs_dist(c, ghosts) for m, c in next_cells.items()}
|
| 148 |
+
|
| 149 |
+
@cat.fn
|
| 150 |
+
def danger(next_cells, ghost_reach):
|
| 151 |
+
"""per move: True if a ghost can be on that cell this tick"""
|
| 152 |
+
reach = set(ghost_reach)
|
| 153 |
+
return {m: c in reach for m, c in next_cells.items()}
|
| 154 |
+
|
| 155 |
+
@cat.fn
|
| 156 |
+
def safe_moves(legal_moves, danger):
|
| 157 |
+
return [m for m in legal_moves if not danger[m]]
|
| 158 |
+
|
| 159 |
+
@cat.fn
|
| 160 |
+
def dots_left(dots):
|
| 161 |
+
return len(dots)
|
| 162 |
+
|
| 163 |
+
cat.fn(greedy or load_bot())
|
| 164 |
+
|
| 165 |
+
@cat.fn
|
| 166 |
+
def evade_move(safe_moves, legal_moves, dot_dist, ghost_dist):
|
| 167 |
+
"""the best safe move: closest dot among safe moves, ties -> farther from ghosts; none safe -> farthest from ghosts"""
|
| 168 |
+
if safe_moves:
|
| 169 |
+
return min(safe_moves, key=lambda m: (dot_dist[m], -ghost_dist[m]))
|
| 170 |
+
return max(legal_moves, key=lambda m: ghost_dist[m])
|
| 171 |
+
|
| 172 |
+
if safety:
|
| 173 |
+
@cat.check(hard=True, then={"move": "EVADE"})
|
| 174 |
+
def greedy_is_safe(greedy_move, danger, safe_moves):
|
| 175 |
+
"""hard: the scored move may step into ghost reach only if every move does"""
|
| 176 |
+
return not danger.get(greedy_move, False) or not safe_moves
|
| 177 |
+
|
| 178 |
+
@cat.rule("move")
|
| 179 |
+
def move(greedy_move):
|
| 180 |
+
return greedy_move
|
| 181 |
+
|
| 182 |
+
@cat.rule("fallback")
|
| 183 |
+
def fallback(evade_move):
|
| 184 |
+
return evade_move
|
| 185 |
+
|
| 186 |
+
return cat
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def build_system(safety: bool = True, greedy=None):
|
| 190 |
+
cat = build_catalog(safety, greedy)
|
| 191 |
+
qs = [Question("move", "Which way should Pac go?", Answer.choice(MOVES + ["EVADE"]),
|
| 192 |
+
checkpoints=["greedy_is_safe"] if safety else []),
|
| 193 |
+
Question("fallback", "Safest good move if the scored move is refused", Answer.choice(MOVES))]
|
| 194 |
+
return cat, System(cat, qs)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 198 |
+
# Game engine
|
| 199 |
+
# --------------------------------------------------------------------------------------------------------------------
|
| 200 |
+
|
| 201 |
+
@dataclass
|
| 202 |
+
class Ghost:
|
| 203 |
+
name: str
|
| 204 |
+
color: str
|
| 205 |
+
corner: tuple
|
| 206 |
+
release: int
|
| 207 |
+
pos: tuple = GHOST_HOME
|
| 208 |
+
dir: str | None = None
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@dataclass
|
| 212 |
+
class MazeGame:
|
| 213 |
+
seed: int = 0
|
| 214 |
+
pac: tuple = PAC_START
|
| 215 |
+
last_move: str | None = None
|
| 216 |
+
ghosts: list = field(default_factory=list)
|
| 217 |
+
dots: set = field(default_factory=lambda: set(ALL_DOTS))
|
| 218 |
+
score: int = 0
|
| 219 |
+
lives: int = LIVES
|
| 220 |
+
tick: int = 0
|
| 221 |
+
life_tick: int = 0
|
| 222 |
+
over: bool = False
|
| 223 |
+
won: bool = False
|
| 224 |
+
deaths: int = 0
|
| 225 |
+
forced: int = 0
|
| 226 |
+
log: list = field(default_factory=list)
|
| 227 |
+
rng: random.Random = field(default_factory=random.Random)
|
| 228 |
+
|
| 229 |
+
def state(self):
|
| 230 |
+
"""init_state for the agent"""
|
| 231 |
+
return {"pac": self.pac, "ghosts": [g.pos for g in self.ghosts], "dots": sorted(self.dots),
|
| 232 |
+
"last_move": self.last_move}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def new_game(seed: int = 0) -> MazeGame:
|
| 236 |
+
g = MazeGame(seed=seed, rng=random.Random(seed))
|
| 237 |
+
g.ghosts = [Ghost(n, col, corner, rel) for n, col, corner, rel in GHOSTS]
|
| 238 |
+
return g
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _ghost_target(game, gh):
|
| 242 |
+
if game.life_tick % CYCLE < SCATTER:
|
| 243 |
+
return gh.corner
|
| 244 |
+
p = game.pac
|
| 245 |
+
if gh.name == "Pinky" and game.last_move:
|
| 246 |
+
dr, dc = DIRS[game.last_move]
|
| 247 |
+
return (p[0] + 2 * dr, p[1] + 2 * dc)
|
| 248 |
+
if gh.name == "Clyde" and abs(gh.pos[0] - p[0]) + abs(gh.pos[1] - p[1]) <= 4:
|
| 249 |
+
return gh.corner
|
| 250 |
+
return p
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _move_ghosts(game):
|
| 254 |
+
for gh in game.ghosts:
|
| 255 |
+
if game.life_tick < gh.release or game.rng.random() > GHOST_SPEED:
|
| 256 |
+
continue
|
| 257 |
+
opts = [(m, c) for m, c in open_neighbors(gh.pos) if m != REVERSE[gh.dir]] or open_neighbors(gh.pos)
|
| 258 |
+
if game.rng.random() < GHOST_NOISE:
|
| 259 |
+
m, c = game.rng.choice(opts)
|
| 260 |
+
else:
|
| 261 |
+
t = _ghost_target(game, gh)
|
| 262 |
+
m, c = min(opts, key=lambda mc: (mc[1][0] - t[0]) ** 2 + (mc[1][1] - t[1]) ** 2)
|
| 263 |
+
gh.pos, gh.dir = c, m
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _caught(game):
|
| 267 |
+
return any(gh.pos == game.pac for gh in game.ghosts)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def _lose_life(game):
|
| 271 |
+
game.lives -= 1
|
| 272 |
+
game.deaths += 1
|
| 273 |
+
game.pac, game.last_move, game.life_tick = PAC_START, None, 0
|
| 274 |
+
for gh in game.ghosts:
|
| 275 |
+
gh.pos, gh.dir = GHOST_HOME, None
|
| 276 |
+
if game.lives <= 0:
|
| 277 |
+
game.over = True
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def agent_decide(system, game, safety=True):
|
| 281 |
+
"""Ask the solvi agent. Returns (move, response, note) where note says whether the hard check fired."""
|
| 282 |
+
names = ["move", "fallback"] if safety else ["move"]
|
| 283 |
+
resp = system.ask(game.state(), names)
|
| 284 |
+
r = resp["move"]
|
| 285 |
+
if r.status == "forced":
|
| 286 |
+
return resp["fallback"].answer, resp, "forced"
|
| 287 |
+
if r.status == "abstain" or r.answer is None:
|
| 288 |
+
legal = [m for m, _ in open_neighbors(game.pac)]
|
| 289 |
+
return legal[0], resp, "abstain"
|
| 290 |
+
return r.answer, resp, "ok"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def describe(move, resp, note):
|
| 294 |
+
"""one log line: 'move LEFT — nearest dot 3 away, ghost 5 away (safe)'"""
|
| 295 |
+
v = resp.values
|
| 296 |
+
dd, gd, dg = v.get("dot_dist", {}), v.get("ghost_dist", {}), v.get("danger", {})
|
| 297 |
+
risky = dg[move] if move in dg else gd.get(move, 99) <= 1 # without the check, danger is not computed: same test
|
| 298 |
+
tail = f"nearest dot {dd.get(move, '?')} away, ghost {gd.get(move, '?')} away ({'DANGER' if risky else 'safe'})"
|
| 299 |
+
if note == "forced":
|
| 300 |
+
want = v.get("greedy_move")
|
| 301 |
+
return f"move {move} — hard check refused {want} (ghost could reach it); {tail}"
|
| 302 |
+
if note == "abstain":
|
| 303 |
+
return f"move {move} — rule abstained ({resp['move'].why[:60]}); {tail}"
|
| 304 |
+
return f"move {move} — {tail}"
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def step(game: MazeGame, move: str, note: str = "") -> str:
|
| 308 |
+
"""Advance one tick with Pac playing `move`. Returns an event string ('' / 'dot' / 'caught' / 'won' / 'over')."""
|
| 309 |
+
if game.over:
|
| 310 |
+
return "over"
|
| 311 |
+
game.tick += 1
|
| 312 |
+
game.life_tick += 1
|
| 313 |
+
if note == "forced":
|
| 314 |
+
game.forced += 1
|
| 315 |
+
nxt = step_cell(game.pac, move)
|
| 316 |
+
if nxt not in WALLS:
|
| 317 |
+
game.pac = nxt
|
| 318 |
+
game.last_move = move
|
| 319 |
+
event = ""
|
| 320 |
+
if game.pac in game.dots:
|
| 321 |
+
game.dots.discard(game.pac)
|
| 322 |
+
game.score += DOT_POINTS
|
| 323 |
+
event = "dot"
|
| 324 |
+
if not game.dots:
|
| 325 |
+
game.score += CLEAR_BONUS
|
| 326 |
+
game.over = game.won = True
|
| 327 |
+
return "won"
|
| 328 |
+
if _caught(game):
|
| 329 |
+
_lose_life(game)
|
| 330 |
+
return "over" if game.over else "caught"
|
| 331 |
+
_move_ghosts(game)
|
| 332 |
+
if _caught(game):
|
| 333 |
+
_lose_life(game)
|
| 334 |
+
return "over" if game.over else "caught"
|
| 335 |
+
if game.tick >= MAX_TICKS:
|
| 336 |
+
game.over = True
|
| 337 |
+
return "over"
|
| 338 |
+
return event
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def run_game(system, seed, safety=True, max_ticks=MAX_TICKS):
|
| 342 |
+
g = new_game(seed)
|
| 343 |
+
while not g.over and g.tick < max_ticks:
|
| 344 |
+
mv, _, note = agent_decide(system, g, safety)
|
| 345 |
+
step(g, mv, note)
|
| 346 |
+
return {"won": g.won, "score": g.score, "dots": TOTAL_DOTS - len(g.dots), "deaths": g.deaths,
|
| 347 |
+
"ticks": g.tick, "forced": g.forced}
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def summarize(results):
|
| 351 |
+
n = len(results)
|
| 352 |
+
return {"games": n,
|
| 353 |
+
"win_rate": sum(r["won"] for r in results) / n,
|
| 354 |
+
"avg_score": sum(r["score"] for r in results) / n,
|
| 355 |
+
"avg_dots_pct": sum(r["dots"] for r in results) / n / TOTAL_DOTS,
|
| 356 |
+
"avg_deaths": sum(r["deaths"] for r in results) / n,
|
| 357 |
+
"avg_forced": sum(r["forced"] for r in results) / n}
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def benchmark(n=100, greedy=None, seeds_from=0):
|
| 361 |
+
"""n seeded games with and without the hard safety check (same seeds, same scoring rule)."""
|
| 362 |
+
out = {}
|
| 363 |
+
for safety in (True, False):
|
| 364 |
+
_, sysm = build_system(safety, greedy)
|
| 365 |
+
out["with_check" if safety else "without_check"] = summarize(
|
| 366 |
+
[run_game(sysm, seeds_from + s, safety) for s in range(n)])
|
| 367 |
+
return out
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def snapshot(game):
|
| 371 |
+
return copy.deepcopy(game)
|
games/sandbox.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run user bot code for the maze in-process, in the visitor's own browser (Pyodide has no subprocesses).
|
| 2 |
+
|
| 3 |
+
The code is exec'd in a fresh namespace. A sys.settrace guard counts the lines executed in the user's code (the file
|
| 4 |
+
"<bot>" only, so solvi and the game engine run at full speed) and stops the run when one call executes too many lines
|
| 5 |
+
(an infinite loop) or the whole run passes a wall-clock deadline. The guard raises BotStopped, a BaseException, so solvi
|
| 6 |
+
(which turns ordinary exceptions of a step into an abstain) cannot swallow it and the run ends at once.
|
| 7 |
+
|
| 8 |
+
This protects against runaway Python loops in the bot. It is not a security sandbox, and it cannot interrupt a loop that
|
| 9 |
+
runs inside C code (for example sum(itertools.count())); there the tab has to be reloaded."""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import builtins
|
| 13 |
+
import functools
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
from . import maze
|
| 18 |
+
|
| 19 |
+
MAX_CODE = 20_000
|
| 20 |
+
N_GAMES = 20
|
| 21 |
+
MAX_LINES_PER_CALL = 200_000 # lines of user code in one call of the bot (or in the module body)
|
| 22 |
+
DEADLINE_S = 30.0 # the whole run of N_GAMES games (about 6 s on a laptop)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class BotStopped(BaseException):
|
| 26 |
+
"""Raised by the guard. BaseException on purpose: solvi catches Exception from a step, not this."""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Guard:
|
| 30 |
+
def __init__(self, max_lines=MAX_LINES_PER_CALL, deadline_s=DEADLINE_S):
|
| 31 |
+
self.max_lines, self.deadline_s = max_lines, deadline_s
|
| 32 |
+
self.t_end = time.perf_counter() + deadline_s
|
| 33 |
+
self.lines = 0
|
| 34 |
+
|
| 35 |
+
def _local(self, frame, event, arg):
|
| 36 |
+
if event == "line":
|
| 37 |
+
self.lines += 1
|
| 38 |
+
if self.lines > self.max_lines:
|
| 39 |
+
raise BotStopped(f"stopped: one call of your code ran more than {self.max_lines:,} lines "
|
| 40 |
+
f"(an infinite loop?), at line {frame.f_lineno}")
|
| 41 |
+
if not self.lines & 1023 and time.perf_counter() > self.t_end:
|
| 42 |
+
raise BotStopped(f"stopped: the run took longer than {self.deadline_s:.0f} s")
|
| 43 |
+
return self._local
|
| 44 |
+
|
| 45 |
+
def _global(self, frame, event, arg):
|
| 46 |
+
return self._local if frame.f_code.co_filename == "<bot>" else None
|
| 47 |
+
|
| 48 |
+
def call(self, f, *args, **kwargs):
|
| 49 |
+
if time.perf_counter() > self.t_end:
|
| 50 |
+
raise BotStopped(f"stopped: the run took longer than {self.deadline_s:.0f} s")
|
| 51 |
+
self.lines = 0
|
| 52 |
+
old = sys.gettrace()
|
| 53 |
+
sys.settrace(self._global)
|
| 54 |
+
try:
|
| 55 |
+
return f(*args, **kwargs)
|
| 56 |
+
finally:
|
| 57 |
+
sys.settrace(old)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def load_guarded(code: str, guard: Guard):
|
| 61 |
+
"""exec the code in a fresh namespace under the guard; return greedy_move wrapped so every call is guarded too."""
|
| 62 |
+
ns = {"__name__": "user_bot", "__builtins__": builtins}
|
| 63 |
+
guard.call(exec, compile(code, "<bot>", "exec"), ns) # noqa: S102 - the visitor's own code, in their own browser
|
| 64 |
+
f = ns.get("greedy_move")
|
| 65 |
+
if not callable(f):
|
| 66 |
+
raise ValueError("the code must define a function named greedy_move")
|
| 67 |
+
|
| 68 |
+
@functools.wraps(f) # keeps the name and the signature: solvi reads the argument names
|
| 69 |
+
def greedy_move(*args, **kwargs):
|
| 70 |
+
return guard.call(f, *args, **kwargs)
|
| 71 |
+
return greedy_move
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _error_tail(e: BaseException) -> str:
|
| 75 |
+
"""the traceback lines that point into the user's code, plus the error itself"""
|
| 76 |
+
import traceback
|
| 77 |
+
lines = traceback.format_exception(type(e), e, e.__traceback__)
|
| 78 |
+
at = next((i for i, ln in enumerate(lines) if 'File "<bot>"' in ln), None)
|
| 79 |
+
return "".join(lines[at:] if at is not None else lines[-1:]).strip()
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def run_bot(code: str, n: int = N_GAMES, safety: bool = True) -> dict:
|
| 83 |
+
"""Returns {"ok": True, "summary": ..., "games": [...], "abstains": k, ...} or {"ok": False, "error": text, "kind": ...}."""
|
| 84 |
+
if len(code) > MAX_CODE:
|
| 85 |
+
return {"ok": False, "kind": "too long", "error": f"code is {len(code)} characters; the limit is {MAX_CODE}"}
|
| 86 |
+
guard = Guard()
|
| 87 |
+
try:
|
| 88 |
+
greedy = load_guarded(code, guard)
|
| 89 |
+
_, system = maze.build_system(safety, greedy)
|
| 90 |
+
games, abstains, first_error = [], 0, None
|
| 91 |
+
for seed in range(n):
|
| 92 |
+
g = maze.new_game(seed)
|
| 93 |
+
while not g.over:
|
| 94 |
+
mv, resp, note = maze.agent_decide(system, g, safety)
|
| 95 |
+
if note == "abstain":
|
| 96 |
+
abstains += 1
|
| 97 |
+
if abstains == 1:
|
| 98 |
+
errs = [r.error for r in resp.trace.records if r.name == "greedy_move" and r.error]
|
| 99 |
+
first_error = errs[0] if errs else resp["move"].why
|
| 100 |
+
maze.step(g, mv, note)
|
| 101 |
+
games.append({"won": g.won, "score": g.score, "dots": maze.TOTAL_DOTS - len(g.dots), "deaths": g.deaths,
|
| 102 |
+
"ticks": g.tick, "forced": g.forced})
|
| 103 |
+
except BotStopped as e:
|
| 104 |
+
return {"ok": False, "kind": "stopped by the guard", "error": str(e)}
|
| 105 |
+
except (KeyboardInterrupt, SystemExit) as e:
|
| 106 |
+
return {"ok": False, "kind": "error", "error": f"{type(e).__name__}: {e}"}
|
| 107 |
+
except Exception as e: # noqa: BLE001 - syntax errors, a missing greedy_move, errors in the module body
|
| 108 |
+
return {"ok": False, "kind": "error", "error": _error_tail(e)}
|
| 109 |
+
out = {"ok": True, "summary": maze.summarize(games), "games": games, "abstains": abstains}
|
| 110 |
+
if abstains:
|
| 111 |
+
out["first_error"] = first_error
|
| 112 |
+
return out
|
games/tictactoe.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tic-tac-toe vs solvi. The catalog is a vendored copy of solvi/examples/05_tic_tac_toe.py (games/_ttt_catalog.py).
|
| 2 |
+
We add one more rule to it, "reason", which names why the move was chosen (win, block, fork ...), so every agent move
|
| 3 |
+
comes with a readable label on top of the rule inputs."""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import random
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
|
| 9 |
+
from solvi import Answer, Question, System
|
| 10 |
+
|
| 11 |
+
from . import _ttt_catalog as _mod
|
| 12 |
+
|
| 13 |
+
SOURCE = "solvi/examples/05_tic_tac_toe.py"
|
| 14 |
+
cat = _mod.cat
|
| 15 |
+
LINES = _mod.LINES
|
| 16 |
+
winner_of = _mod._winner
|
| 17 |
+
|
| 18 |
+
REASONS = {
|
| 19 |
+
"win": "completes a line and wins",
|
| 20 |
+
"block": "blocks your line",
|
| 21 |
+
"fork": "creates two threats at once (a fork)",
|
| 22 |
+
"block_fork": "takes your only fork cell",
|
| 23 |
+
"forcing": "makes a threat you must answer, so you cannot build your fork",
|
| 24 |
+
"center": "takes the center (nothing urgent on the board)",
|
| 25 |
+
"corner": "takes a corner (nothing urgent on the board)",
|
| 26 |
+
"edge": "takes an edge (nothing urgent on the board)",
|
| 27 |
+
"none": "no move",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@cat.rule("reason")
|
| 32 |
+
def move_reason(winning_moves, blocking_moves, fork_moves, opponent_forks, forcing_moves, free_cells):
|
| 33 |
+
"""mirrors pick_move's order and names the branch that decided"""
|
| 34 |
+
for label, group in (("win", winning_moves), ("block", blocking_moves), ("fork", fork_moves)):
|
| 35 |
+
if group:
|
| 36 |
+
return label
|
| 37 |
+
if len(opponent_forks) == 1:
|
| 38 |
+
return "block_fork"
|
| 39 |
+
if opponent_forks and forcing_moves:
|
| 40 |
+
return "forcing"
|
| 41 |
+
for c in (4, 0, 2, 6, 8, 1, 3, 5, 7):
|
| 42 |
+
if c in free_cells:
|
| 43 |
+
return "center" if c == 4 else "corner" if c in (0, 2, 6, 8) else "edge"
|
| 44 |
+
return "none"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
QUESTIONS = list(_mod.QUESTIONS) + [
|
| 48 |
+
Question("reason", "Why this move?", Answer.choice(list(REASONS)), checkpoints=["board_valid"])]
|
| 49 |
+
system = System(cat, QUESTIONS)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class TTTGame:
|
| 54 |
+
human: str = "X"
|
| 55 |
+
board: str = "." * 9
|
| 56 |
+
over: bool = False
|
| 57 |
+
result: str = "" # "agent" | "human" | "draw"
|
| 58 |
+
last_agent_cell: int | None = None
|
| 59 |
+
counted: bool = False # already added to the win/loss counters
|
| 60 |
+
history: list = field(default_factory=list)
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def agent(self):
|
| 64 |
+
return "O" if self.human == "X" else "X"
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def turn(self):
|
| 68 |
+
return "X" if self.board.count("X") == self.board.count("O") else "O"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _finish(game):
|
| 72 |
+
w = winner_of(game.board)
|
| 73 |
+
if w or "." not in game.board:
|
| 74 |
+
game.over = True
|
| 75 |
+
game.result = "draw" if not w else ("agent" if w == game.agent else "human")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def agent_move(game):
|
| 79 |
+
"""The agent plays one move. Returns the solvi Response (or None if it was not the agent's turn)."""
|
| 80 |
+
if game.over or game.turn != game.agent:
|
| 81 |
+
return None
|
| 82 |
+
resp = system.ask({"board": game.board, "me": game.agent}, ["move", "reason"])
|
| 83 |
+
ans = resp["move"].answer
|
| 84 |
+
if ans is None or ans == "none":
|
| 85 |
+
return resp
|
| 86 |
+
c = int(ans)
|
| 87 |
+
game.board = game.board[:c] + game.agent + game.board[c + 1:]
|
| 88 |
+
game.last_agent_cell = c
|
| 89 |
+
game.history.append((game.agent, c, resp["reason"].answer))
|
| 90 |
+
_finish(game)
|
| 91 |
+
return resp
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def human_move(game, cell):
|
| 95 |
+
"""Returns an error text, or '' when the move was played."""
|
| 96 |
+
if game.over:
|
| 97 |
+
return "The game is over. Press New game."
|
| 98 |
+
if game.turn != game.human:
|
| 99 |
+
return "Wait for the agent."
|
| 100 |
+
if game.board[cell] != ".":
|
| 101 |
+
return "That cell is taken."
|
| 102 |
+
game.board = game.board[:cell] + game.human + game.board[cell + 1:]
|
| 103 |
+
game.history.append((game.human, cell, None))
|
| 104 |
+
_finish(game)
|
| 105 |
+
return ""
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def new_game(human="X"):
|
| 109 |
+
g = TTTGame(human=human)
|
| 110 |
+
resp = agent_move(g) if g.agent == "X" else None
|
| 111 |
+
return g, resp
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def explain_position(board, me):
|
| 115 |
+
"""All facts for `me` on this board (the 'explain the position' view)."""
|
| 116 |
+
return system.ask({"board": board, "me": me})
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def vs_random(n=500, seed=0):
|
| 120 |
+
"""n games against a uniformly random opponent, agent side alternating at random. Returns counts."""
|
| 121 |
+
rng = random.Random(seed)
|
| 122 |
+
out = {"agent": 0, "draw": 0, "human": 0}
|
| 123 |
+
for _ in range(n):
|
| 124 |
+
g, _ = new_game(rng.choice("XO"))
|
| 125 |
+
while not g.over:
|
| 126 |
+
if g.turn == g.human:
|
| 127 |
+
human_move(g, rng.choice([i for i in range(9) if g.board[i] == "."]))
|
| 128 |
+
else:
|
| 129 |
+
agent_move(g)
|
| 130 |
+
out[g.result] += 1
|
| 131 |
+
return out
|
index.html
CHANGED
|
@@ -1,19 +1,106 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>solvi arcade</title>
|
| 7 |
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22%3E%3Ctext y=%22.9em%22 font-size=%2290%22%3E🕹️%3C/text%3E%3C/svg%3E" />
|
| 8 |
+
<meta name="description" content="Game agents built with solvi from small Python functions, rules and hard checks. They explain every move and run entirely in your browser." />
|
| 9 |
+
<script>
|
| 10 |
+
// Package "time machine" for Gradio-Lite 5.45.0 (released 2025-09-10).
|
| 11 |
+
// Its Python worker installs gradio 5.45 with micropip, whose resolver is greedy: gradio_client asks for
|
| 12 |
+
// huggingface-hub>=0.19.3 and gets 1.x/2.x, and then gradio's huggingface-hub<1.0 pin (and anyio<5, ...) fails.
|
| 13 |
+
// Fix: inside the worker, PyPI's simple index is filtered to files uploaded before the Gradio-Lite release, so every
|
| 14 |
+
// package resolves to what existed when that release was built. solvi is exempt (it needs the current version).
|
| 15 |
+
(() => {
|
| 16 |
+
const prelude = `(() => {
|
| 17 |
+
const CUTOFF = Date.parse("2025-09-10T17:00:00Z");
|
| 18 |
+
const EXEMPT = new Set(["solvi"]);
|
| 19 |
+
const realFetch = self.fetch.bind(self);
|
| 20 |
+
self.fetch = async (input, init) => {
|
| 21 |
+
const url = typeof input === "string" ? input : (input && input.url) || String(input);
|
| 22 |
+
const m = /^https:\\/\\/pypi\\.org\\/simple\\/([^/?#]+)\\/?$/.exec(url);
|
| 23 |
+
const resp = await realFetch(input, init);
|
| 24 |
+
if (!m || EXEMPT.has(m[1].toLowerCase()) || !resp.ok) return resp;
|
| 25 |
+
try {
|
| 26 |
+
const data = await resp.clone().json();
|
| 27 |
+
if (!Array.isArray(data.files)) return resp;
|
| 28 |
+
const kept = data.files.filter(f => !f["upload-time"] || Date.parse(f["upload-time"]) <= CUTOFF);
|
| 29 |
+
if (!kept.length) return resp;
|
| 30 |
+
data.files = kept;
|
| 31 |
+
return new Response(JSON.stringify(data), { status: 200, headers: {
|
| 32 |
+
"Content-Type": resp.headers.get("Content-Type") || "application/vnd.pypi.simple.v1+json" } });
|
| 33 |
+
} catch (e) { return resp; }
|
| 34 |
+
};
|
| 35 |
+
})();`;
|
| 36 |
+
const RealWorker = window.Worker;
|
| 37 |
+
window.Worker = function (url, opts) {
|
| 38 |
+
const boot = new Blob([prelude, "\nimportScripts(" + JSON.stringify(new URL(String(url), location.href).href) + ");"], { type: "text/javascript" });
|
| 39 |
+
return new RealWorker(URL.createObjectURL(boot), opts);
|
| 40 |
+
};
|
| 41 |
+
window.Worker.prototype = RealWorker.prototype;
|
| 42 |
+
})();
|
| 43 |
+
</script>
|
| 44 |
+
<script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/@gradio/lite@5.45.0/dist/lite.js"></script>
|
| 45 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@gradio/lite@5.45.0/dist/lite.css" />
|
| 46 |
+
<style>
|
| 47 |
+
:root { --sv-bg: #ffffff; --sv-fg: #1f2937; --sv-dim: #6b7280; --sv-card: #f6f3ff; --sv-border: #ddd6fe; --sv-accent: #7c3aed; }
|
| 48 |
+
@media (prefers-color-scheme: dark) {
|
| 49 |
+
:root:not([data-theme="light"]) { --sv-bg: #0b0f19; --sv-fg: #e5e7eb; --sv-dim: #9ca3af; --sv-card: #1a1530; --sv-border: #3b2f6b; --sv-accent: #a78bfa; }
|
| 50 |
+
}
|
| 51 |
+
:root[data-theme="dark"] { --sv-bg: #0b0f19; --sv-fg: #e5e7eb; --sv-dim: #9ca3af; --sv-card: #1a1530; --sv-border: #3b2f6b; --sv-accent: #a78bfa; }
|
| 52 |
+
html, body { margin: 0; background: var(--sv-bg); }
|
| 53 |
+
#sv-loading { max-width: 720px; margin: 24px auto 0; padding: 0 16px; box-sizing: border-box;
|
| 54 |
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--sv-fg); }
|
| 55 |
+
#sv-loading .card { background: var(--sv-card); border: 1px solid var(--sv-border); border-radius: 14px; padding: 16px 18px; }
|
| 56 |
+
#sv-loading h1 { font-size: 1.35rem; margin: 0 0 6px; }
|
| 57 |
+
#sv-loading p { margin: 6px 0; line-height: 1.45; font-size: 0.95rem; }
|
| 58 |
+
#sv-loading .dim { color: var(--sv-dim); font-size: 0.85rem; }
|
| 59 |
+
#sv-loading a { color: var(--sv-accent); }
|
| 60 |
+
#sv-loading .bar { height: 4px; border-radius: 2px; background: var(--sv-border); overflow: hidden; margin-top: 12px; }
|
| 61 |
+
#sv-loading .bar div { width: 30%; height: 100%; background: var(--sv-accent); border-radius: 2px; animation: sv-slide 1.4s ease-in-out infinite; }
|
| 62 |
+
@keyframes sv-slide { 0% { margin-left: -30%; } 100% { margin-left: 100%; } }
|
| 63 |
+
#sv-loading.gone { display: none; }
|
| 64 |
+
</style>
|
| 65 |
+
</head>
|
| 66 |
+
<body>
|
| 67 |
+
<div id="sv-loading" role="status">
|
| 68 |
+
<div class="card">
|
| 69 |
+
<h1>🕹️ solvi arcade is starting in your browser</h1>
|
| 70 |
+
<p>The game agents are Python, and they run on your machine, not on a server. The first visit downloads a Python
|
| 71 |
+
runtime (Pyodide) with numpy, scipy, Gradio and <a href="https://github.com/solvi-ai/solvi">solvi</a>:
|
| 72 |
+
about 20–30 MB, once. Later visits load it from the browser cache.</p>
|
| 73 |
+
<p class="dim">This usually takes 20–60 seconds on the first visit.</p>
|
| 74 |
+
<div class="bar"><div></div></div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<gradio-lite>
|
| 79 |
+
<gradio-requirements>
|
| 80 |
+
solvi>=0.1.1
|
| 81 |
+
</gradio-requirements>
|
| 82 |
+
<gradio-file name="app.py" url="app.py" entrypoint></gradio-file>
|
| 83 |
+
<gradio-file name="games/__init__.py" url="games/__init__.py"></gradio-file>
|
| 84 |
+
<gradio-file name="games/_ttt_catalog.py" url="games/_ttt_catalog.py"></gradio-file>
|
| 85 |
+
<gradio-file name="games/tictactoe.py" url="games/tictactoe.py"></gradio-file>
|
| 86 |
+
<gradio-file name="games/explain.py" url="games/explain.py"></gradio-file>
|
| 87 |
+
<gradio-file name="games/maze.py" url="games/maze.py"></gradio-file>
|
| 88 |
+
<gradio-file name="games/fusion.py" url="games/fusion.py"></gradio-file>
|
| 89 |
+
<gradio-file name="games/sandbox.py" url="games/sandbox.py"></gradio-file>
|
| 90 |
+
</gradio-lite>
|
| 91 |
+
|
| 92 |
+
<script>
|
| 93 |
+
// Gradio-Lite fetches file URLs from its web worker, where a relative URL would not resolve against this page.
|
| 94 |
+
// This classic script runs before the deferred lite.js module, so the URLs are absolute when it reads them.
|
| 95 |
+
for (const el of document.querySelectorAll("gradio-file[url]")) {
|
| 96 |
+
el.setAttribute("url", new URL(el.getAttribute("url"), document.baseURI).href);
|
| 97 |
+
}
|
| 98 |
+
// Hide the loading note once the app has rendered its header.
|
| 99 |
+
const note = document.getElementById("sv-loading");
|
| 100 |
+
const obs = new MutationObserver(() => {
|
| 101 |
+
if (document.getElementById("hero")) { note.classList.add("gone"); obs.disconnect(); }
|
| 102 |
+
});
|
| 103 |
+
obs.observe(document.body, { childList: true, subtree: true });
|
| 104 |
+
</script>
|
| 105 |
+
</body>
|
| 106 |
</html>
|