mxkuzn commited on
Commit
096700a
·
verified ·
1 Parent(s): a1cd603

solvi arcade: game agents that explain every move, running in your browser

Browse files
Files changed (10) hide show
  1. README.md +68 -4
  2. app.py +614 -0
  3. games/__init__.py +0 -0
  4. games/_ttt_catalog.py +108 -0
  5. games/explain.py +52 -0
  6. games/fusion.py +275 -0
  7. games/maze.py +371 -0
  8. games/sandbox.py +112 -0
  9. games/tictactoe.py +131 -0
  10. index.html +104 -17
README.md CHANGED
@@ -1,10 +1,74 @@
1
  ---
2
- title: Arcade
3
- emoji: 🚀
4
- colorFrom: pink
5
  colorTo: yellow
6
  sdk: static
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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} &nbsp;·&nbsp; **Lives** {'❤️' * game.lives or '—'} &nbsp;·&nbsp; "
193
+ f"**Dots** {eaten}/{maze.TOTAL_DOTS} &nbsp;·&nbsp; **Tick** {game.tick} &nbsp;·&nbsp; "
194
+ f"**hard-check vetoes** {game.forced} &nbsp;·&nbsp; 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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>