twisty 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twisty-0.1.0/PKG-INFO +10 -0
- twisty-0.1.0/cube/__init__.py +4 -0
- twisty-0.1.0/cube/live.py +631 -0
- twisty-0.1.0/cube/render.py +114 -0
- twisty-0.1.0/cube/server.py +169 -0
- twisty-0.1.0/cube/state.py +97 -0
- twisty-0.1.0/cube/view.py +74 -0
- twisty-0.1.0/pyproject.toml +24 -0
- twisty-0.1.0/setup.cfg +4 -0
- twisty-0.1.0/twisty.egg-info/PKG-INFO +10 -0
- twisty-0.1.0/twisty.egg-info/SOURCES.txt +13 -0
- twisty-0.1.0/twisty.egg-info/dependency_links.txt +1 -0
- twisty-0.1.0/twisty.egg-info/entry_points.txt +2 -0
- twisty-0.1.0/twisty.egg-info/requires.txt +6 -0
- twisty-0.1.0/twisty.egg-info/top_level.txt +1 -0
twisty-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twisty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Partially-observable twisty puzzle POMDP — an MCP server for AI agent benchmarking
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: mcp[cli]>=1.26.0
|
|
7
|
+
Requires-Dist: magiccube>=1.2.0
|
|
8
|
+
Requires-Dist: Pillow>=10.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from socketserver import ThreadingMixIn
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
_BUNDLE = Path(__file__).resolve().parent / "twisty.bundle.js"
|
|
13
|
+
|
|
14
|
+
_data: dict = {}
|
|
15
|
+
_events: list[dict] = []
|
|
16
|
+
_cond = threading.Condition()
|
|
17
|
+
_seq = 0
|
|
18
|
+
_epoch = 0
|
|
19
|
+
|
|
20
|
+
FACE_ORDER = ("U", "D", "F", "B", "L", "R")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def facelets(faces: dict[str, list[list[str]]]) -> str:
|
|
24
|
+
chars = []
|
|
25
|
+
for name in FACE_ORDER:
|
|
26
|
+
grid = faces[name]
|
|
27
|
+
if name == "U":
|
|
28
|
+
for r in (2, 1, 0):
|
|
29
|
+
for c in range(3):
|
|
30
|
+
chars.append(grid[r][c].lower())
|
|
31
|
+
elif name == "L":
|
|
32
|
+
for r in range(3):
|
|
33
|
+
for c in (2, 1, 0):
|
|
34
|
+
chars.append(grid[r][c].lower())
|
|
35
|
+
else:
|
|
36
|
+
for c in range(3):
|
|
37
|
+
for r in range(3):
|
|
38
|
+
chars.append(grid[r][c].lower())
|
|
39
|
+
return "".join(chars)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def update(faces, viewpoint, stats, move=None, event=None,
|
|
43
|
+
scramble_alg="", alg=""):
|
|
44
|
+
global _seq
|
|
45
|
+
with _cond:
|
|
46
|
+
_data["facelets"] = facelets(faces)
|
|
47
|
+
_data["viewpoint"] = viewpoint.name
|
|
48
|
+
_data["viewpoint_id"] = viewpoint.id
|
|
49
|
+
_data["visible"] = list(viewpoint.faces)
|
|
50
|
+
_data["stats"] = stats
|
|
51
|
+
_data["move"] = move
|
|
52
|
+
_data["event"] = event
|
|
53
|
+
_data["epoch"] = _epoch
|
|
54
|
+
_data["scramble_alg"] = scramble_alg
|
|
55
|
+
_data["alg"] = alg
|
|
56
|
+
_data["ts"] = time.time()
|
|
57
|
+
if event:
|
|
58
|
+
_events.append(event)
|
|
59
|
+
_seq += 1
|
|
60
|
+
_cond.notify_all()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def push(event):
|
|
64
|
+
global _seq
|
|
65
|
+
with _cond:
|
|
66
|
+
_events.append(event)
|
|
67
|
+
_data["move"] = None
|
|
68
|
+
_data["event"] = event
|
|
69
|
+
_data["ts"] = time.time()
|
|
70
|
+
_seq += 1
|
|
71
|
+
_cond.notify_all()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clear():
|
|
75
|
+
global _epoch
|
|
76
|
+
with _cond:
|
|
77
|
+
_events.clear()
|
|
78
|
+
_epoch += 1
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _Server(ThreadingMixIn, HTTPServer):
|
|
82
|
+
daemon_threads = True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
86
|
+
def do_GET(self):
|
|
87
|
+
parsed = urlparse(self.path)
|
|
88
|
+
if parsed.path == "/state":
|
|
89
|
+
self._json()
|
|
90
|
+
elif parsed.path == "/events":
|
|
91
|
+
self._sse()
|
|
92
|
+
elif parsed.path == "/twisty.bundle.js":
|
|
93
|
+
self._bundle()
|
|
94
|
+
else:
|
|
95
|
+
self._html(parsed)
|
|
96
|
+
|
|
97
|
+
def _bundle(self):
|
|
98
|
+
body = _BUNDLE.read_bytes()
|
|
99
|
+
self.send_response(200)
|
|
100
|
+
self.send_header("Content-Type", "application/javascript")
|
|
101
|
+
self.send_header("Cache-Control", "public, max-age=86400")
|
|
102
|
+
self.end_headers()
|
|
103
|
+
self.wfile.write(body)
|
|
104
|
+
|
|
105
|
+
def _json(self):
|
|
106
|
+
with _cond:
|
|
107
|
+
d = dict(_data)
|
|
108
|
+
d["events"] = list(_events)
|
|
109
|
+
body = json.dumps(d).encode()
|
|
110
|
+
self.send_response(200)
|
|
111
|
+
self.send_header("Content-Type", "application/json")
|
|
112
|
+
self.send_header("Cache-Control", "no-store")
|
|
113
|
+
self.end_headers()
|
|
114
|
+
self.wfile.write(body)
|
|
115
|
+
|
|
116
|
+
def _sse(self):
|
|
117
|
+
self.send_response(200)
|
|
118
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
119
|
+
self.send_header("Cache-Control", "no-cache")
|
|
120
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
121
|
+
self.end_headers()
|
|
122
|
+
local = -1
|
|
123
|
+
try:
|
|
124
|
+
while True:
|
|
125
|
+
with _cond:
|
|
126
|
+
changed = _cond.wait_for(lambda: _seq > local, timeout=15)
|
|
127
|
+
local = _seq
|
|
128
|
+
if changed:
|
|
129
|
+
d = dict(_data)
|
|
130
|
+
d["events"] = list(_events)
|
|
131
|
+
if changed:
|
|
132
|
+
self.wfile.write(f"data: {json.dumps(d)}\n\n".encode())
|
|
133
|
+
else:
|
|
134
|
+
self.wfile.write(b":\n\n")
|
|
135
|
+
self.wfile.flush()
|
|
136
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def _html(self, parsed):
|
|
140
|
+
pip = "pip" in parsed.query
|
|
141
|
+
self.send_response(200)
|
|
142
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
143
|
+
self.end_headers()
|
|
144
|
+
self.wfile.write(_page(pip).encode())
|
|
145
|
+
|
|
146
|
+
def log_message(self, *args):
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def start(port: int = 4321) -> int:
|
|
151
|
+
for p in range(port, port + 10):
|
|
152
|
+
try:
|
|
153
|
+
server = _Server(("127.0.0.1", p), _Handler)
|
|
154
|
+
break
|
|
155
|
+
except OSError:
|
|
156
|
+
continue
|
|
157
|
+
else:
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
161
|
+
thread.start()
|
|
162
|
+
|
|
163
|
+
url = f"http://localhost:{p}"
|
|
164
|
+
try:
|
|
165
|
+
webbrowser.open(url)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return p
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _page(pip=False):
|
|
172
|
+
return _HTML.replace("__PIP__", "true" if pip else "false")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
_HTML = r"""<!DOCTYPE html>
|
|
176
|
+
<html lang="en">
|
|
177
|
+
<head>
|
|
178
|
+
<meta charset="utf-8">
|
|
179
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
180
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
181
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
182
|
+
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
|
|
183
|
+
<title>Rubik's Cube Partially Observable MDP</title>
|
|
184
|
+
<style>
|
|
185
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
186
|
+
body {
|
|
187
|
+
font-family: 'Space Grotesk', -apple-system, system-ui, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
188
|
+
background: #000;
|
|
189
|
+
color: #fff;
|
|
190
|
+
display: flex;
|
|
191
|
+
flex-direction: column;
|
|
192
|
+
align-items: center;
|
|
193
|
+
min-height: 100vh;
|
|
194
|
+
padding: 12px 24px;
|
|
195
|
+
}
|
|
196
|
+
h1 {
|
|
197
|
+
font-size: 15px;
|
|
198
|
+
font-weight: 600;
|
|
199
|
+
color: #fff;
|
|
200
|
+
letter-spacing: 1.5px;
|
|
201
|
+
text-transform: uppercase;
|
|
202
|
+
margin-bottom: 6px;
|
|
203
|
+
}
|
|
204
|
+
#cube {
|
|
205
|
+
width: min(55vmin, calc(100vh - 280px));
|
|
206
|
+
height: min(55vmin, calc(100vh - 280px));
|
|
207
|
+
}
|
|
208
|
+
#cube twisty-player {
|
|
209
|
+
width: 100%;
|
|
210
|
+
height: 100%;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#meta {
|
|
214
|
+
margin-top: 6px;
|
|
215
|
+
border: 1px solid #333;
|
|
216
|
+
border-radius: 12px;
|
|
217
|
+
padding: 14px 18px 12px;
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
align-items: center;
|
|
221
|
+
width: fit-content;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#vp-net {
|
|
225
|
+
display: grid;
|
|
226
|
+
grid-template-columns: repeat(4, 30px);
|
|
227
|
+
grid-template-rows: repeat(3, 30px);
|
|
228
|
+
gap: 3px;
|
|
229
|
+
justify-content: center;
|
|
230
|
+
}
|
|
231
|
+
.net-cell {
|
|
232
|
+
width: 30px;
|
|
233
|
+
height: 30px;
|
|
234
|
+
border-radius: 5px;
|
|
235
|
+
border: 1px solid rgba(255,255,255,0.1);
|
|
236
|
+
transition: opacity 0.25s ease;
|
|
237
|
+
display: flex;
|
|
238
|
+
align-items: center;
|
|
239
|
+
justify-content: center;
|
|
240
|
+
font-size: 12px;
|
|
241
|
+
font-weight: 700;
|
|
242
|
+
}
|
|
243
|
+
.net-cell.dim { opacity: 0.15; }
|
|
244
|
+
|
|
245
|
+
#stats {
|
|
246
|
+
margin-top: 16px;
|
|
247
|
+
padding-top: 14px;
|
|
248
|
+
border-top: 1px solid #222;
|
|
249
|
+
display: grid;
|
|
250
|
+
grid-template-columns: 1fr auto;
|
|
251
|
+
width: 129px;
|
|
252
|
+
gap: 4px 12px;
|
|
253
|
+
font-size: 13px;
|
|
254
|
+
}
|
|
255
|
+
.stat-label { color: #fff; }
|
|
256
|
+
.stat-value { text-align: right; font-weight: 600; }
|
|
257
|
+
|
|
258
|
+
#activity {
|
|
259
|
+
margin-top: 14px;
|
|
260
|
+
padding-top: 12px;
|
|
261
|
+
border-top: 1px solid #222;
|
|
262
|
+
display: flex;
|
|
263
|
+
justify-content: center;
|
|
264
|
+
width: 100%;
|
|
265
|
+
min-height: 24px;
|
|
266
|
+
}
|
|
267
|
+
#activity:empty { display: none; }
|
|
268
|
+
.pill {
|
|
269
|
+
display: inline-flex;
|
|
270
|
+
align-items: center;
|
|
271
|
+
gap: 5px;
|
|
272
|
+
font-size: 12px;
|
|
273
|
+
font-weight: 600;
|
|
274
|
+
text-transform: uppercase;
|
|
275
|
+
letter-spacing: 0.3px;
|
|
276
|
+
padding: 5px 12px;
|
|
277
|
+
border-radius: 100px;
|
|
278
|
+
border: 1px solid;
|
|
279
|
+
background: rgba(255,255,255,0.04);
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
cursor: pointer;
|
|
282
|
+
transition: background 0.15s;
|
|
283
|
+
}
|
|
284
|
+
.pill .arg { font-weight: 400; opacity: 0.7; letter-spacing: 0; text-transform: none; }
|
|
285
|
+
.pill:hover { background: rgba(255,255,255,0.1); }
|
|
286
|
+
|
|
287
|
+
#overlay {
|
|
288
|
+
position: fixed; inset: 0;
|
|
289
|
+
background: rgba(0,0,0,0.5);
|
|
290
|
+
z-index: 199;
|
|
291
|
+
opacity: 0; visibility: hidden;
|
|
292
|
+
transition: opacity 0.15s ease, visibility 0.15s ease;
|
|
293
|
+
}
|
|
294
|
+
#overlay.open { opacity: 1; visibility: visible; }
|
|
295
|
+
|
|
296
|
+
#history {
|
|
297
|
+
position: fixed; top: 50%; left: 50%;
|
|
298
|
+
width: min(300px, calc(100vw - 32px));
|
|
299
|
+
max-height: min(400px, 70vh);
|
|
300
|
+
opacity: 0; visibility: hidden; pointer-events: none;
|
|
301
|
+
transform: translate(-50%, -50%) scale(0.96);
|
|
302
|
+
transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease;
|
|
303
|
+
z-index: 200;
|
|
304
|
+
background: #111; border: 1px solid #333; border-radius: 8px;
|
|
305
|
+
overflow: hidden; display: flex; flex-direction: column;
|
|
306
|
+
}
|
|
307
|
+
#history.open {
|
|
308
|
+
opacity: 1; visibility: visible; pointer-events: auto;
|
|
309
|
+
transform: translate(-50%, -50%) scale(1);
|
|
310
|
+
}
|
|
311
|
+
#history-list { overflow-y: auto; scrollbar-width: thin; scrollbar-color: #333 transparent; }
|
|
312
|
+
#history-list table { width: 100%; border-collapse: collapse; }
|
|
313
|
+
#history-list th, #history-list td { border: 1px solid #222; padding: 6px 10px; text-align: left; }
|
|
314
|
+
#history-list th {
|
|
315
|
+
position: sticky; top: 0; background: #111;
|
|
316
|
+
font-size: 11px; font-weight: 600; color: #555;
|
|
317
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
318
|
+
}
|
|
319
|
+
#history-list th:first-child, #history-list td:first-child { text-align: right; width: 28px; }
|
|
320
|
+
#history-list td { font-size: 13px; }
|
|
321
|
+
#history-list tr:last-child td { background: rgba(255,255,255,0.04); }
|
|
322
|
+
.step-num { text-align: right; color: #555; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
323
|
+
.step-label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; font-size: 12px; }
|
|
324
|
+
.step-detail { color: #aaa; font-size: 13px; }
|
|
325
|
+
#history-list tr:last-child .step-detail { color: #ddd; }
|
|
326
|
+
|
|
327
|
+
#pip-btn {
|
|
328
|
+
margin-top: 16px; background: transparent; color: #aaa;
|
|
329
|
+
border: 1px solid #333; border-radius: 6px; padding: 6px 16px;
|
|
330
|
+
font-size: 13px; font-family: inherit; cursor: pointer; transition: all 0.15s;
|
|
331
|
+
}
|
|
332
|
+
#pip-btn:hover { background: #222; color: #fff; border-color: #555; }
|
|
333
|
+
|
|
334
|
+
#note {
|
|
335
|
+
margin-top: 10px; max-width: 400px; font-size: 11px;
|
|
336
|
+
color: #aaa; text-align: center; line-height: 1.45;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
body.pip { padding: 0; justify-content: center; background: #000; }
|
|
340
|
+
body.pip h1, body.pip #meta, body.pip #pip-btn, body.pip #note,
|
|
341
|
+
body.pip #history, body.pip #overlay { display: none; }
|
|
342
|
+
body.pip #cube { width: 100vw; height: 100vh; }
|
|
343
|
+
</style>
|
|
344
|
+
</head>
|
|
345
|
+
<body>
|
|
346
|
+
<h1>Rubik's Cube Partially Observable MDP</h1>
|
|
347
|
+
<div id="cube"></div>
|
|
348
|
+
<div id="meta">
|
|
349
|
+
<div id="vp-net"></div>
|
|
350
|
+
<div id="stats">
|
|
351
|
+
<div class="stat-label">Moves</div><div class="stat-value" id="s-moves">0</div>
|
|
352
|
+
<div class="stat-label">Inspections</div><div class="stat-value" id="s-insp">0</div>
|
|
353
|
+
</div>
|
|
354
|
+
<div id="activity"></div>
|
|
355
|
+
</div>
|
|
356
|
+
<div id="overlay"></div>
|
|
357
|
+
<div id="history">
|
|
358
|
+
<div id="history-list"></div>
|
|
359
|
+
</div>
|
|
360
|
+
<button id="pip-btn">Picture in Picture</button>
|
|
361
|
+
<div id="note">Click the last action for full history.<br>Use Picture in Picture to watch the cube while the agent works in another window.</div>
|
|
362
|
+
<script>
|
|
363
|
+
const IS_PIP = __PIP__;
|
|
364
|
+
if (IS_PIP) document.body.classList.add('pip');
|
|
365
|
+
|
|
366
|
+
async function main() {
|
|
367
|
+
const { TwistyPlayer } = await import("/twisty.bundle.js");
|
|
368
|
+
|
|
369
|
+
const player = new TwistyPlayer({
|
|
370
|
+
puzzle: "3x3x3",
|
|
371
|
+
alg: "",
|
|
372
|
+
experimentalSetupAlg: "",
|
|
373
|
+
background: "none",
|
|
374
|
+
controlPanel: "none",
|
|
375
|
+
hintFacelets: "none",
|
|
376
|
+
tempoScale: 2.5,
|
|
377
|
+
});
|
|
378
|
+
document.getElementById('cube').appendChild(player);
|
|
379
|
+
|
|
380
|
+
const VIEWPOINTS = {
|
|
381
|
+
0: { lat: 30, lon: 45 },
|
|
382
|
+
1: { lat: 30, lon: 315 },
|
|
383
|
+
2: { lat: 30, lon: 225 },
|
|
384
|
+
3: { lat: 30, lon: 135 },
|
|
385
|
+
4: { lat: -30, lon: 45 },
|
|
386
|
+
5: { lat: -30, lon: 315 },
|
|
387
|
+
6: { lat: -30, lon: 225 },
|
|
388
|
+
7: { lat: -30, lon: 135 },
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const FACE_COLORS = {U:'#ffffff',D:'#fdd835',F:'#43a047',B:'#1e88e5',L:'#fb8c00',R:'#e53935'};
|
|
392
|
+
const FACE_TEXT_COLORS = {U:'#333',D:'#333',F:'#fff',B:'#fff',L:'#333',R:'#fff'};
|
|
393
|
+
const NET = [null,'U',null,null,'L','F','R','B',null,'D',null,null];
|
|
394
|
+
|
|
395
|
+
let lastTs = 0;
|
|
396
|
+
let viewId = 0;
|
|
397
|
+
let curScrambleAlg = '';
|
|
398
|
+
let curAlg = '';
|
|
399
|
+
let toolLog = [];
|
|
400
|
+
let seenEvents = 0;
|
|
401
|
+
let epoch = 0;
|
|
402
|
+
|
|
403
|
+
function setCamera(vid) {
|
|
404
|
+
const vp = VIEWPOINTS[vid] || VIEWPOINTS[0];
|
|
405
|
+
try {
|
|
406
|
+
player.experimentalModel.twistySceneModel.orbitCoordinatesRequest.set({
|
|
407
|
+
latitude: vp.lat, longitude: vp.lon,
|
|
408
|
+
});
|
|
409
|
+
} catch (e) {
|
|
410
|
+
player.cameraLatitude = vp.lat;
|
|
411
|
+
player.cameraLongitude = vp.lon;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
setCamera(0);
|
|
415
|
+
|
|
416
|
+
function renderNet(visible) {
|
|
417
|
+
const vis = new Set(visible || []);
|
|
418
|
+
const el = document.getElementById('vp-net');
|
|
419
|
+
el.innerHTML = '';
|
|
420
|
+
for (const face of NET) {
|
|
421
|
+
const cell = document.createElement('div');
|
|
422
|
+
if (face) {
|
|
423
|
+
cell.className = 'net-cell' + (vis.has(face) ? '' : ' dim');
|
|
424
|
+
cell.style.background = FACE_COLORS[face];
|
|
425
|
+
cell.style.color = FACE_TEXT_COLORS[face];
|
|
426
|
+
cell.textContent = face;
|
|
427
|
+
}
|
|
428
|
+
el.appendChild(cell);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
renderNet(['F', 'R', 'U']);
|
|
432
|
+
|
|
433
|
+
function visibleFromCamera(lat, lon) {
|
|
434
|
+
const lr = lat * Math.PI / 180;
|
|
435
|
+
const lo = lon * Math.PI / 180;
|
|
436
|
+
const vis = [];
|
|
437
|
+
if (Math.sin(lr) > 0.01) vis.push('U');
|
|
438
|
+
if (Math.sin(lr) < -0.01) vis.push('D');
|
|
439
|
+
if (Math.cos(lo) > 0.01) vis.push('F');
|
|
440
|
+
if (Math.cos(lo) < -0.01) vis.push('B');
|
|
441
|
+
if (Math.sin(lo) > 0.01) vis.push('R');
|
|
442
|
+
if (Math.sin(lo) < -0.01) vis.push('L');
|
|
443
|
+
return vis;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let orbitDriven = false;
|
|
447
|
+
try {
|
|
448
|
+
player.experimentalModel.twistySceneModel.orbitCoordinates
|
|
449
|
+
.addFreshListener((c) => {
|
|
450
|
+
orbitDriven = true;
|
|
451
|
+
renderNet(visibleFromCamera(c.latitude, c.longitude));
|
|
452
|
+
});
|
|
453
|
+
} catch (e) {}
|
|
454
|
+
|
|
455
|
+
const TOOL_COLORS = {
|
|
456
|
+
scramble:'#fdd835', reset:'#fb8c00', look:'#666',
|
|
457
|
+
move:'#ccc', rotate_view:'#1e88e5',
|
|
458
|
+
is_solved:'#43a047', get_history:'#888', get_stats:'#888'
|
|
459
|
+
};
|
|
460
|
+
const CHIP_LABELS = {
|
|
461
|
+
scramble:'SCRAMBLE',reset:'RESET',look:'LOOK',move:'MOVE',
|
|
462
|
+
rotate_view:'ROTATE',is_solved:'CHECK',get_history:'HISTORY',get_stats:'STATS'
|
|
463
|
+
};
|
|
464
|
+
const FALLBACK_DETAILS = { look: 'current view', get_stats: 'snapshot' };
|
|
465
|
+
|
|
466
|
+
function escapeHTML(v) {
|
|
467
|
+
return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
468
|
+
.replace(/"/g,'"').replace(/'/g,''');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function eventMeta(e) {
|
|
472
|
+
const label = CHIP_LABELS[e.tool] || e.tool.toUpperCase();
|
|
473
|
+
const color = TOOL_COLORS[e.tool] || '#555';
|
|
474
|
+
const display = e.display || '';
|
|
475
|
+
const callMatch = display.match(/^[^(]+\(([^)]*)\)/);
|
|
476
|
+
const resultMatch = display.match(/\)\s*→\s*(.+)$/);
|
|
477
|
+
const compactDetail = resultMatch && resultMatch[1]
|
|
478
|
+
? resultMatch[1].trim()
|
|
479
|
+
: callMatch && callMatch[1] ? callMatch[1].trim() : '';
|
|
480
|
+
return { label, color, compactDetail, detail: compactDetail || FALLBACK_DETAILS[e.tool] || '-' };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function pillHTML(e) {
|
|
484
|
+
const m = eventMeta(e);
|
|
485
|
+
return '<span class="pill" style="color:'+m.color+';border-color:'+m.color+'">'+
|
|
486
|
+
escapeHTML(m.label)+(m.compactDetail?'<span class="arg">'+escapeHTML(m.compactDetail)+'</span>':'')+'</span>';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function renderActivity() {
|
|
490
|
+
const el = document.getElementById('activity');
|
|
491
|
+
if (!toolLog.length) { el.innerHTML = ''; return; }
|
|
492
|
+
el.innerHTML = pillHTML(toolLog[toolLog.length - 1]);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function rowHTML(e, i) {
|
|
496
|
+
const m = eventMeta(e);
|
|
497
|
+
const detail = m.detail !== '-' ? escapeHTML(m.detail) : '';
|
|
498
|
+
return '<tr><td class="step-num">'+(i+1)+'</td><td class="step-label" style="color:'+m.color+'">'+
|
|
499
|
+
escapeHTML(m.label)+'</td><td class="step-detail">'+detail+'</td></tr>';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderHistory() {
|
|
503
|
+
const el = document.getElementById('history-list');
|
|
504
|
+
if (!toolLog.length) return;
|
|
505
|
+
el.innerHTML = '<table><thead><tr><th>#</th><th>Action</th><th>Value</th></tr></thead><tbody>'+
|
|
506
|
+
toolLog.map(rowHTML).join('')+'</tbody></table>';
|
|
507
|
+
el.scrollTop = el.scrollHeight;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function updateStats(d) {
|
|
511
|
+
const s = d.stats || {};
|
|
512
|
+
document.getElementById('s-moves').textContent = s.moves ?? '-';
|
|
513
|
+
document.getElementById('s-insp').textContent = s.inspections ?? '-';
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const historyEl = document.getElementById('history');
|
|
517
|
+
const overlayEl = document.getElementById('overlay');
|
|
518
|
+
const activityEl = document.getElementById('activity');
|
|
519
|
+
|
|
520
|
+
function closeHistory() { historyEl.classList.remove('open'); overlayEl.classList.remove('open'); }
|
|
521
|
+
function openHistory() { renderHistory(); historyEl.classList.add('open'); overlayEl.classList.add('open'); }
|
|
522
|
+
|
|
523
|
+
function handleState(d) {
|
|
524
|
+
if (!d.ts || d.ts === lastTs) return;
|
|
525
|
+
lastTs = d.ts;
|
|
526
|
+
|
|
527
|
+
const events = d.events || [];
|
|
528
|
+
const newEpoch = d.epoch || 0;
|
|
529
|
+
if (newEpoch !== epoch) {
|
|
530
|
+
epoch = newEpoch;
|
|
531
|
+
toolLog = []; seenEvents = 0;
|
|
532
|
+
closeHistory();
|
|
533
|
+
}
|
|
534
|
+
if (events.length > seenEvents) {
|
|
535
|
+
for (let i = seenEvents; i < events.length; i++) toolLog.push(events[i]);
|
|
536
|
+
seenEvents = events.length;
|
|
537
|
+
renderActivity();
|
|
538
|
+
if (historyEl.classList.contains('open')) renderHistory();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const scrambleAlg = d.scramble_alg || '';
|
|
542
|
+
const alg = d.alg || '';
|
|
543
|
+
|
|
544
|
+
try {
|
|
545
|
+
if (scrambleAlg !== curScrambleAlg) {
|
|
546
|
+
curScrambleAlg = scrambleAlg;
|
|
547
|
+
curAlg = alg;
|
|
548
|
+
player.experimentalSetupAlg = scrambleAlg;
|
|
549
|
+
player.alg = alg;
|
|
550
|
+
player.timestamp = "end";
|
|
551
|
+
} else if (d.move && alg !== curAlg) {
|
|
552
|
+
curAlg = alg;
|
|
553
|
+
player.experimentalAddMove(d.move);
|
|
554
|
+
} else if (alg !== curAlg) {
|
|
555
|
+
curAlg = alg;
|
|
556
|
+
player.alg = alg;
|
|
557
|
+
player.timestamp = "end";
|
|
558
|
+
}
|
|
559
|
+
} catch (e) {
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const vid = d.viewpoint_id;
|
|
563
|
+
if (vid !== undefined && vid !== viewId) {
|
|
564
|
+
viewId = vid;
|
|
565
|
+
setCamera(vid);
|
|
566
|
+
}
|
|
567
|
+
if (!orbitDriven && d.visible) renderNet(d.visible);
|
|
568
|
+
updateStats(d);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const _es = new EventSource('/events');
|
|
572
|
+
_es.onmessage = (e) => handleState(JSON.parse(e.data));
|
|
573
|
+
|
|
574
|
+
activityEl.addEventListener('click', () => {
|
|
575
|
+
if (!toolLog.length) return;
|
|
576
|
+
historyEl.classList.contains('open') ? closeHistory() : openHistory();
|
|
577
|
+
});
|
|
578
|
+
overlayEl.addEventListener('click', closeHistory);
|
|
579
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeHistory(); });
|
|
580
|
+
|
|
581
|
+
let pipVid = null;
|
|
582
|
+
|
|
583
|
+
(async function setupPip() {
|
|
584
|
+
for (let i = 0; i < 30; i++) {
|
|
585
|
+
try {
|
|
586
|
+
const canvases = await player.experimentalCurrentCanvases();
|
|
587
|
+
const src = canvases[0];
|
|
588
|
+
if (src && typeof src.captureStream === 'function') {
|
|
589
|
+
pipVid = document.createElement('video');
|
|
590
|
+
pipVid.srcObject = src.captureStream(30);
|
|
591
|
+
pipVid.muted = true;
|
|
592
|
+
pipVid.playsInline = true;
|
|
593
|
+
pipVid.style.cssText = 'position:fixed;opacity:0;pointer-events:none;width:1px;height:1px';
|
|
594
|
+
document.body.appendChild(pipVid);
|
|
595
|
+
await pipVid.play();
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
} catch (e) {}
|
|
599
|
+
await new Promise(r => setTimeout(r, 500));
|
|
600
|
+
}
|
|
601
|
+
})();
|
|
602
|
+
|
|
603
|
+
document.getElementById('pip-btn').addEventListener('click', async () => {
|
|
604
|
+
if (document.pictureInPictureElement) {
|
|
605
|
+
await document.exitPictureInPicture();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
if (!pipVid) throw new Error('PiP not ready — canvas not available');
|
|
610
|
+
await pipVid.requestPictureInPicture();
|
|
611
|
+
} catch (err) {
|
|
612
|
+
const msg = 'PiP failed: ' + err.message;
|
|
613
|
+
const toast = document.createElement('div');
|
|
614
|
+
toast.textContent = msg;
|
|
615
|
+
toast.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);background:#e53935;color:#fff;padding:8px 16px;border-radius:6px;font-size:13px;z-index:999';
|
|
616
|
+
document.body.appendChild(toast);
|
|
617
|
+
setTimeout(() => toast.remove(), 5000);
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
} // end main
|
|
622
|
+
|
|
623
|
+
main().catch(err => {
|
|
624
|
+
document.getElementById('cube').innerHTML =
|
|
625
|
+
'<div style="color:#e53935;padding:24px;font-size:14px;">' +
|
|
626
|
+
'Failed to load cubing.js: ' + err.message +
|
|
627
|
+
'<br><br>Check browser console for details.</div>';
|
|
628
|
+
});
|
|
629
|
+
</script>
|
|
630
|
+
</body>
|
|
631
|
+
</html>"""
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
COLORS: dict[str, tuple[int, int, int]] = {
|
|
8
|
+
"R": (220, 30, 30),
|
|
9
|
+
"O": (255, 127, 0),
|
|
10
|
+
"W": (242, 242, 242),
|
|
11
|
+
"Y": (255, 220, 0),
|
|
12
|
+
"B": (30, 80, 220),
|
|
13
|
+
"G": (30, 200, 60),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
HIDDEN = (80, 80, 80)
|
|
17
|
+
BG = (30, 30, 30)
|
|
18
|
+
BORDER = (0, 0, 0)
|
|
19
|
+
|
|
20
|
+
NET: dict[str, tuple[int, int]] = {
|
|
21
|
+
"U": (1, 0),
|
|
22
|
+
"L": (0, 1),
|
|
23
|
+
"F": (1, 1),
|
|
24
|
+
"R": (2, 1),
|
|
25
|
+
"B": (3, 1),
|
|
26
|
+
"D": (1, 2),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ALL_FACES = ("U", "L", "F", "R", "B", "D")
|
|
30
|
+
|
|
31
|
+
HATCH_STEP = 12
|
|
32
|
+
HATCH_COLOR = (92, 92, 92)
|
|
33
|
+
PILL_RADIUS = 5
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pill(w: int, h: int, fill: tuple[int, int, int, int]) -> Image.Image:
|
|
37
|
+
pill = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
38
|
+
ImageDraw.Draw(pill).rounded_rectangle(
|
|
39
|
+
[0, 0, w - 1, h - 1], radius=PILL_RADIUS, fill=fill,
|
|
40
|
+
)
|
|
41
|
+
return pill
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render(
|
|
45
|
+
faces: dict[str, list[list[str]]],
|
|
46
|
+
visible: set[str],
|
|
47
|
+
cell: int = 48,
|
|
48
|
+
) -> bytes:
|
|
49
|
+
n = 3
|
|
50
|
+
pad = 14
|
|
51
|
+
gap = 3
|
|
52
|
+
face_px = n * cell + (n + 1) * gap
|
|
53
|
+
|
|
54
|
+
w = 4 * face_px + 2 * pad
|
|
55
|
+
h = 3 * face_px + 2 * pad
|
|
56
|
+
|
|
57
|
+
img = Image.new("RGB", (w, h), BG)
|
|
58
|
+
draw = ImageDraw.Draw(img)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
font = ImageFont.truetype(
|
|
62
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 20,
|
|
63
|
+
)
|
|
64
|
+
except OSError:
|
|
65
|
+
font = ImageFont.load_default()
|
|
66
|
+
|
|
67
|
+
for name in ALL_FACES:
|
|
68
|
+
col, row = NET[name]
|
|
69
|
+
ox = pad + col * face_px
|
|
70
|
+
oy = pad + row * face_px
|
|
71
|
+
|
|
72
|
+
if name in visible and name in faces:
|
|
73
|
+
grid = faces[name]
|
|
74
|
+
for r in range(n):
|
|
75
|
+
for c in range(n):
|
|
76
|
+
color = COLORS.get(grid[r][c], HIDDEN)
|
|
77
|
+
x0 = ox + gap + c * (cell + gap)
|
|
78
|
+
y0 = oy + gap + r * (cell + gap)
|
|
79
|
+
draw.rectangle(
|
|
80
|
+
[x0, y0, x0 + cell - 1, y0 + cell - 1],
|
|
81
|
+
fill=color, outline=BORDER, width=1,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
cx = ox + gap + (cell + gap) + cell // 2
|
|
85
|
+
cy = oy + gap + (cell + gap) + cell // 2
|
|
86
|
+
bb = font.getbbox(name)
|
|
87
|
+
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
|
88
|
+
pp = 6
|
|
89
|
+
pill = _pill(tw + pp * 2, th + pp * 2, (0, 0, 0, 140))
|
|
90
|
+
img.paste(pill, (cx - pill.width // 2, cy - pill.height // 2), pill)
|
|
91
|
+
draw.text((cx, cy), name, fill=(255, 255, 255), font=font, anchor="mm")
|
|
92
|
+
else:
|
|
93
|
+
fx, fy = ox + gap, oy + gap
|
|
94
|
+
fw, fh = face_px - 2 * gap, face_px - 2 * gap
|
|
95
|
+
|
|
96
|
+
tile = Image.new("RGB", (fw, fh), HIDDEN)
|
|
97
|
+
td = ImageDraw.Draw(tile)
|
|
98
|
+
for off in range(-fh, fw + fh, HATCH_STEP):
|
|
99
|
+
td.line([(off, 0), (off + fh, fh)], fill=HATCH_COLOR, width=1)
|
|
100
|
+
td.rectangle([0, 0, fw - 1, fh - 1], outline=(60, 60, 60), width=1)
|
|
101
|
+
img.paste(tile, (fx, fy))
|
|
102
|
+
|
|
103
|
+
cx = ox + face_px // 2
|
|
104
|
+
cy = oy + face_px // 2
|
|
105
|
+
bb = font.getbbox(name)
|
|
106
|
+
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
|
107
|
+
pp = 7
|
|
108
|
+
pill = _pill(tw + pp * 2, th + pp * 2, (60, 60, 60, 210))
|
|
109
|
+
img.paste(pill, (cx - pill.width // 2, cy - pill.height // 2), pill)
|
|
110
|
+
draw.text((cx, cy), name, fill=(140, 140, 140), font=font, anchor="mm")
|
|
111
|
+
|
|
112
|
+
buf = BytesIO()
|
|
113
|
+
img.save(buf, format="PNG")
|
|
114
|
+
return buf.getvalue()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
7
|
+
|
|
8
|
+
from cube import live
|
|
9
|
+
from cube.render import render
|
|
10
|
+
from cube.state import State
|
|
11
|
+
from cube.view import Viewpoint
|
|
12
|
+
|
|
13
|
+
_ARROWS = {"left": "←", "right": "→", "up": "↑", "down": "↓"}
|
|
14
|
+
|
|
15
|
+
mcp = FastMCP(
|
|
16
|
+
"Rubik Cube POMDP",
|
|
17
|
+
instructions=(
|
|
18
|
+
"You are solving a partially-observable Rubik's cube.\n"
|
|
19
|
+
"The cube is FIXED in space: White=Up, Yellow=Down, Green=Front, "
|
|
20
|
+
"Blue=Back, Red=Right, Orange=Left.\n"
|
|
21
|
+
"From any viewpoint you see exactly 3 of 6 faces. The other 3 are hidden.\n"
|
|
22
|
+
"Use rotate_view to change which faces are visible (costs 1 inspection step).\n"
|
|
23
|
+
"Use move to apply a face turn (costs 1 move step).\n"
|
|
24
|
+
"Use look to re-read the current view for free.\n"
|
|
25
|
+
"Efficiency metric: minimize total steps (moves + inspections).\n"
|
|
26
|
+
"Start: scramble() -> look() -> plan -> move/rotate_view -> repeat until solved."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_state = State()
|
|
31
|
+
_view = Viewpoint(0)
|
|
32
|
+
_lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _observe(move=None, event: dict | None = None) -> tuple[list, bytes]:
|
|
36
|
+
visible = set(_view.faces)
|
|
37
|
+
all_faces = _state.faces
|
|
38
|
+
img = render(all_faces, visible)
|
|
39
|
+
|
|
40
|
+
lines = [f"Viewpoint: {_view.name} (id={_view.id})"]
|
|
41
|
+
lines.append(f"Visible faces: {', '.join(sorted(visible))}")
|
|
42
|
+
lines.append(f"Hidden faces: {', '.join(sorted(set('UDLRFB') - visible))}")
|
|
43
|
+
lines.append("")
|
|
44
|
+
|
|
45
|
+
for name in sorted(visible):
|
|
46
|
+
grid = all_faces[name]
|
|
47
|
+
lines.append(f" {name}:")
|
|
48
|
+
for row in grid:
|
|
49
|
+
lines.append(f" {' '.join(row)}")
|
|
50
|
+
lines.append("")
|
|
51
|
+
lines.append(f"Solved: {_state.solved}")
|
|
52
|
+
|
|
53
|
+
live.update(_state.faces, _view, _state.stats, move=move, event=event,
|
|
54
|
+
scramble_alg=_state.scramble_alg, alg=_state.alg)
|
|
55
|
+
|
|
56
|
+
return [Image(data=img, format="png"), "\n".join(lines)], img
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@mcp.tool()
|
|
60
|
+
async def scramble(num_moves: int = 20) -> list:
|
|
61
|
+
"""Scramble the cube with random moves to start a new puzzle. Resets camera to Front-Right-Top."""
|
|
62
|
+
async with _lock:
|
|
63
|
+
global _state, _view
|
|
64
|
+
_state = State()
|
|
65
|
+
moves = _state.scramble(num_moves)
|
|
66
|
+
_view = Viewpoint(0)
|
|
67
|
+
live.clear()
|
|
68
|
+
result, img = _observe(event={"tool": "scramble", "display": f"scramble({num_moves})"})
|
|
69
|
+
result.append(f"\nScrambled with {num_moves} random moves. Use look and rotate_view to explore the cube state.")
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
async def reset() -> list:
|
|
75
|
+
"""Reset the cube to solved state. Clears all history and counters."""
|
|
76
|
+
async with _lock:
|
|
77
|
+
global _state, _view
|
|
78
|
+
_state = State()
|
|
79
|
+
_view = Viewpoint(0)
|
|
80
|
+
live.clear()
|
|
81
|
+
result, img = _observe(event={"tool": "reset", "display": "reset()"})
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
async def look() -> list:
|
|
87
|
+
"""Get the current view. Free action (no step cost)."""
|
|
88
|
+
async with _lock:
|
|
89
|
+
result, img = _observe(event={"tool": "look", "display": "look()"})
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@mcp.tool()
|
|
94
|
+
async def rotate_view(direction: Literal["left", "right", "up", "down"]) -> list:
|
|
95
|
+
"""Rotate camera one step. Changes which 3 faces are visible. Costs 1 inspection step.
|
|
96
|
+
|
|
97
|
+
- left/right: orbit around the cube horizontally
|
|
98
|
+
- up/down: switch between top and bottom viewing angles
|
|
99
|
+
"""
|
|
100
|
+
async with _lock:
|
|
101
|
+
global _view
|
|
102
|
+
old = _view
|
|
103
|
+
_view = _view.rotate(direction)
|
|
104
|
+
_state.tick_inspection()
|
|
105
|
+
|
|
106
|
+
arrow = _ARROWS.get(direction, direction)
|
|
107
|
+
result, img = _observe(event={"tool": "rotate_view", "display": f"rotate_view({arrow})"})
|
|
108
|
+
if old == _view:
|
|
109
|
+
result.append(f"\nNote: already at boundary, viewpoint unchanged (still {_view.name}).")
|
|
110
|
+
else:
|
|
111
|
+
result.append(f"\nRotated {arrow}: {old.name} → {_view.name}")
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@mcp.tool()
|
|
116
|
+
async def move(notation: str) -> list:
|
|
117
|
+
"""Apply one cube move. Valid: R R' R2 U U' U2 F F' F2 L L' L2 D D' D2 B B' B2.
|
|
118
|
+
|
|
119
|
+
The cube is fixed in space: R always turns the Right face clockwise.
|
|
120
|
+
Costs 1 move step.
|
|
121
|
+
"""
|
|
122
|
+
async with _lock:
|
|
123
|
+
try:
|
|
124
|
+
_state.apply(notation)
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
return [str(e)]
|
|
127
|
+
result, img = _observe(
|
|
128
|
+
move=notation,
|
|
129
|
+
event={"tool": "move", "display": f"move({notation})"},
|
|
130
|
+
)
|
|
131
|
+
result.append(f"\nApplied: {notation}")
|
|
132
|
+
if _state.solved:
|
|
133
|
+
result.append("CONGRATULATIONS! The cube is solved!")
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@mcp.tool()
|
|
138
|
+
async def is_solved() -> bool:
|
|
139
|
+
"""Check if the cube is solved. Free action."""
|
|
140
|
+
async with _lock:
|
|
141
|
+
result = _state.solved
|
|
142
|
+
live.push({"tool": "is_solved", "display": f"is_solved() → {str(result).lower()}"})
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@mcp.tool()
|
|
147
|
+
async def get_history() -> str:
|
|
148
|
+
"""Get the list of moves applied since last scramble/reset."""
|
|
149
|
+
async with _lock:
|
|
150
|
+
moves = _state.history
|
|
151
|
+
result = " ".join(moves) if moves else "No moves applied yet."
|
|
152
|
+
live.push({"tool": "get_history", "display": f"get_history() → {len(moves)} moves"})
|
|
153
|
+
return result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
async def get_stats() -> dict:
|
|
158
|
+
"""Get performance statistics: moves, inspections, total steps, scramble size, solved status."""
|
|
159
|
+
async with _lock:
|
|
160
|
+
result = _state.stats
|
|
161
|
+
live.push({"tool": "get_stats", "display": "get_stats()"})
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main():
|
|
166
|
+
live.start()
|
|
167
|
+
mcp.run()
|
|
168
|
+
|
|
169
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import magiccube
|
|
4
|
+
from magiccube.cube_base import Color, Face
|
|
5
|
+
|
|
6
|
+
VALID_MOVES = frozenset(
|
|
7
|
+
f"{f}{s}"
|
|
8
|
+
for f in "RULFDB"
|
|
9
|
+
for s in ("", "'", "2")
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
_FACE = {
|
|
13
|
+
"U": Face.U, "D": Face.D, "F": Face.F,
|
|
14
|
+
"B": Face.B, "R": Face.R, "L": Face.L,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_COLOR = {c: c.name for c in Color}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class State:
|
|
21
|
+
def __init__(self) -> None:
|
|
22
|
+
self._cube = magiccube.Cube(3)
|
|
23
|
+
self._log: list[str] = []
|
|
24
|
+
self._scramble_log: list[str] = []
|
|
25
|
+
self._moves = 0
|
|
26
|
+
self._inspections = 0
|
|
27
|
+
self._scrambled = 0
|
|
28
|
+
|
|
29
|
+
def scramble(self, n: int = 20) -> list[str]:
|
|
30
|
+
self._cube.reset()
|
|
31
|
+
self._cube.scramble(num_steps=n)
|
|
32
|
+
moves = [str(m) for m in self._cube.history()]
|
|
33
|
+
self._scramble_log = moves
|
|
34
|
+
self._log = []
|
|
35
|
+
self._moves = 0
|
|
36
|
+
self._inspections = 0
|
|
37
|
+
self._scrambled = n
|
|
38
|
+
return moves
|
|
39
|
+
|
|
40
|
+
def reset(self) -> None:
|
|
41
|
+
self._cube.reset()
|
|
42
|
+
self._scramble_log = []
|
|
43
|
+
self._log = []
|
|
44
|
+
self._moves = 0
|
|
45
|
+
self._inspections = 0
|
|
46
|
+
self._scrambled = 0
|
|
47
|
+
|
|
48
|
+
def apply(self, notation: str) -> None:
|
|
49
|
+
notation = notation.strip().replace("’", "'").replace("‘", "'").replace("′", "'").replace("`", "'")
|
|
50
|
+
if notation not in VALID_MOVES:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"Invalid move '{notation}'. "
|
|
53
|
+
f"Valid: {' '.join(sorted(VALID_MOVES))}"
|
|
54
|
+
)
|
|
55
|
+
self._cube.rotate(notation)
|
|
56
|
+
self._log.append(notation)
|
|
57
|
+
self._moves += 1
|
|
58
|
+
|
|
59
|
+
def tick_inspection(self) -> None:
|
|
60
|
+
self._inspections += 1
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def solved(self) -> bool:
|
|
64
|
+
return self._cube.is_done()
|
|
65
|
+
|
|
66
|
+
def face(self, name: str) -> list[list[str]]:
|
|
67
|
+
f = _FACE.get(name.upper())
|
|
68
|
+
if f is None:
|
|
69
|
+
raise ValueError(f"Unknown face '{name}'. Valid: {' '.join(_FACE)}")
|
|
70
|
+
grid = self._cube.get_face(f)
|
|
71
|
+
return [[_COLOR[c] for c in row] for row in grid]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def faces(self) -> dict[str, list[list[str]]]:
|
|
75
|
+
return {n: self.face(n) for n in _FACE}
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def history(self) -> list[str]:
|
|
79
|
+
return list(self._log)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def scramble_alg(self) -> str:
|
|
83
|
+
return " ".join(self._scramble_log)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def alg(self) -> str:
|
|
87
|
+
return " ".join(self._log)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def stats(self) -> dict:
|
|
91
|
+
return {
|
|
92
|
+
"moves": self._moves,
|
|
93
|
+
"inspections": self._inspections,
|
|
94
|
+
"total": self._moves + self._inspections,
|
|
95
|
+
"scrambled": self._scrambled,
|
|
96
|
+
"solved": self.solved,
|
|
97
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
POINTS = (
|
|
4
|
+
{"id": 0, "name": "Front-Right-Top", "azim": 45, "elev": 30, "faces": ("F", "R", "U")},
|
|
5
|
+
{"id": 1, "name": "Front-Left-Top", "azim": 315, "elev": 30, "faces": ("F", "L", "U")},
|
|
6
|
+
{"id": 2, "name": "Back-Left-Top", "azim": 225, "elev": 30, "faces": ("B", "L", "U")},
|
|
7
|
+
{"id": 3, "name": "Back-Right-Top", "azim": 135, "elev": 30, "faces": ("B", "R", "U")},
|
|
8
|
+
{"id": 4, "name": "Front-Right-Bottom", "azim": 45, "elev": -30, "faces": ("F", "R", "D")},
|
|
9
|
+
{"id": 5, "name": "Front-Left-Bottom", "azim": 315, "elev": -30, "faces": ("F", "L", "D")},
|
|
10
|
+
{"id": 6, "name": "Back-Left-Bottom", "azim": 225, "elev": -30, "faces": ("B", "L", "D")},
|
|
11
|
+
{"id": 7, "name": "Back-Right-Bottom", "azim": 135, "elev": -30, "faces": ("B", "R", "D")},
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
TRANSITIONS: dict[tuple[int, str], int] = {
|
|
15
|
+
# right: clockwise orbit within ring
|
|
16
|
+
(0, "right"): 3, (3, "right"): 2, (2, "right"): 1, (1, "right"): 0,
|
|
17
|
+
(4, "right"): 7, (7, "right"): 6, (6, "right"): 5, (5, "right"): 4,
|
|
18
|
+
# left: counter-clockwise orbit
|
|
19
|
+
(0, "left"): 1, (1, "left"): 2, (2, "left"): 3, (3, "left"): 0,
|
|
20
|
+
(4, "left"): 5, (5, "left"): 6, (6, "left"): 7, (7, "left"): 4,
|
|
21
|
+
# up: bottom -> top at same column; top stays
|
|
22
|
+
(0, "up"): 0, (1, "up"): 1, (2, "up"): 2, (3, "up"): 3,
|
|
23
|
+
(4, "up"): 0, (5, "up"): 1, (6, "up"): 2, (7, "up"): 3,
|
|
24
|
+
# down: top -> bottom at same column; bottom stays
|
|
25
|
+
(0, "down"): 4, (1, "down"): 5, (2, "down"): 6, (3, "down"): 7,
|
|
26
|
+
(4, "down"): 4, (5, "down"): 5, (6, "down"): 6, (7, "down"): 7,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
DIRECTIONS = frozenset(("left", "right", "up", "down"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Viewpoint:
|
|
33
|
+
__slots__ = ("_idx",)
|
|
34
|
+
|
|
35
|
+
def __init__(self, idx: int = 0) -> None:
|
|
36
|
+
if not 0 <= idx <= 7:
|
|
37
|
+
raise ValueError(f"Viewpoint index must be 0-7, got {idx}")
|
|
38
|
+
self._idx = idx
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def id(self) -> int:
|
|
42
|
+
return self._idx
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
return POINTS[self._idx]["name"]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def faces(self) -> tuple[str, ...]:
|
|
50
|
+
return POINTS[self._idx]["faces"]
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def azim(self) -> float:
|
|
54
|
+
return POINTS[self._idx]["azim"]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def elev(self) -> float:
|
|
58
|
+
return POINTS[self._idx]["elev"]
|
|
59
|
+
|
|
60
|
+
def rotate(self, direction: str) -> Viewpoint:
|
|
61
|
+
if direction not in DIRECTIONS:
|
|
62
|
+
raise ValueError(f"Direction must be one of {sorted(DIRECTIONS)}, got '{direction}'")
|
|
63
|
+
return Viewpoint(TRANSITIONS[(self._idx, direction)])
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"Viewpoint({self._idx}, {self.name!r}, faces={self.faces})"
|
|
67
|
+
|
|
68
|
+
def __eq__(self, other: object) -> bool:
|
|
69
|
+
if isinstance(other, Viewpoint):
|
|
70
|
+
return self._idx == other._idx
|
|
71
|
+
return NotImplemented
|
|
72
|
+
|
|
73
|
+
def __hash__(self) -> int:
|
|
74
|
+
return hash(self._idx)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=45", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "twisty"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Partially-observable twisty puzzle POMDP — an MCP server for AI agent benchmarking"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp[cli]>=1.26.0",
|
|
12
|
+
"magiccube>=1.2.0",
|
|
13
|
+
"Pillow>=10.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=8.0.0"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
cube-server = "cube.server:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
include-package-data = true
|
|
24
|
+
packages = ["cube"]
|
twisty-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twisty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Partially-observable twisty puzzle POMDP — an MCP server for AI agent benchmarking
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: mcp[cli]>=1.26.0
|
|
7
|
+
Requires-Dist: magiccube>=1.2.0
|
|
8
|
+
Requires-Dist: Pillow>=10.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
cube/__init__.py
|
|
3
|
+
cube/live.py
|
|
4
|
+
cube/render.py
|
|
5
|
+
cube/server.py
|
|
6
|
+
cube/state.py
|
|
7
|
+
cube/view.py
|
|
8
|
+
twisty.egg-info/PKG-INFO
|
|
9
|
+
twisty.egg-info/SOURCES.txt
|
|
10
|
+
twisty.egg-info/dependency_links.txt
|
|
11
|
+
twisty.egg-info/entry_points.txt
|
|
12
|
+
twisty.egg-info/requires.txt
|
|
13
|
+
twisty.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cube
|