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 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,4 @@
1
+ from cube.state import State
2
+ from cube.view import Viewpoint
3
+
4
+ __all__ = ["State", "Viewpoint"]
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
468
+ .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ cube-server = cube.server:main
@@ -0,0 +1,6 @@
1
+ mcp[cli]>=1.26.0
2
+ magiccube>=1.2.0
3
+ Pillow>=10.0
4
+
5
+ [dev]
6
+ pytest>=8.0.0
@@ -0,0 +1 @@
1
+ cube