tostido commited on
Commit
cb468dd
·
1 Parent(s): b040880

Centered board layout, replay overlay, mobile responsive CSS

Browse files
src/app_clean.py ADDED
@@ -0,0 +1,722 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CASCADE-LATTICE Chess Demo
3
+ Clean rebuild with Dash + Three.js
4
+ - Human plays White (select from HOLD candidates, then COMMIT)
5
+ - Engine plays Black (auto-responds after COMMIT)
6
+ - Full informational wealth display (features, reasoning, imagination)
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import platform
12
+ import asyncio
13
+ import shutil
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+ from dataclasses import dataclass
18
+ from typing import List, Dict, Any, Optional
19
+
20
+ import chess
21
+ import chess.engine
22
+ import numpy as np
23
+ import dash
24
+ from dash import dcc, html, callback, Input, Output, State, ctx
25
+ import json
26
+
27
+ # ═══════════════════════════════════════════════════════════════════════════════
28
+ # ENGINE SETUP
29
+ # ═══════════════════════════════════════════════════════════════════════════════
30
+
31
+ PROJECT_ROOT = Path(__file__).parent.parent
32
+ LOCAL_STOCKFISH_SSE41 = PROJECT_ROOT / "stockfish" / "stockfish" / "stockfish-windows-x86-64-sse41-popcnt.exe"
33
+ LOCAL_STOCKFISH_AVX2 = PROJECT_ROOT / "stockfish" / "stockfish-windows-x86-64-avx2.exe"
34
+
35
+ if LOCAL_STOCKFISH_SSE41.exists():
36
+ STOCKFISH_PATH = str(LOCAL_STOCKFISH_SSE41)
37
+ elif LOCAL_STOCKFISH_AVX2.exists():
38
+ STOCKFISH_PATH = str(LOCAL_STOCKFISH_AVX2)
39
+ elif shutil.which("stockfish"):
40
+ STOCKFISH_PATH = shutil.which("stockfish")
41
+ else:
42
+ STOCKFISH_PATH = "/usr/games/stockfish"
43
+
44
+ print(f"[STOCKFISH] {STOCKFISH_PATH}")
45
+
46
+ if platform.system() == 'Windows':
47
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
48
+
49
+ # Thread-safe engine
50
+ ENGINE_LOCK = threading.Lock()
51
+ ENGINE = None
52
+
53
+ def get_engine():
54
+ global ENGINE
55
+ with ENGINE_LOCK:
56
+ if ENGINE is None:
57
+ try:
58
+ ENGINE = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH)
59
+ print("[ENGINE] Started")
60
+ except Exception as e:
61
+ print(f"[ENGINE] Failed: {e}")
62
+ return ENGINE
63
+
64
+ def engine_analyse(board, limit, multipv=1):
65
+ """Thread-safe engine analysis."""
66
+ global ENGINE
67
+ with ENGINE_LOCK:
68
+ if ENGINE is None:
69
+ get_engine()
70
+ if ENGINE:
71
+ try:
72
+ return ENGINE.analyse(board, limit, multipv=multipv)
73
+ except chess.engine.EngineTerminatedError:
74
+ print("[ENGINE] Crashed, restarting...")
75
+ ENGINE = None
76
+ get_engine()
77
+ if ENGINE:
78
+ return ENGINE.analyse(board, limit, multipv=multipv)
79
+ return []
80
+
81
+ def engine_play(board, limit):
82
+ """Thread-safe engine play."""
83
+ global ENGINE
84
+ with ENGINE_LOCK:
85
+ if ENGINE is None:
86
+ get_engine()
87
+ if ENGINE:
88
+ try:
89
+ return ENGINE.play(board, limit)
90
+ except chess.engine.EngineTerminatedError:
91
+ print("[ENGINE] Crashed, restarting...")
92
+ ENGINE = None
93
+ get_engine()
94
+ if ENGINE:
95
+ return ENGINE.play(board, limit)
96
+ return None
97
+
98
+ # Initial engine load
99
+ get_engine()
100
+
101
+ # ═══════════════════════════════════════════════════════════════════════════════
102
+ # CASCADE-LATTICE
103
+ # ═══════════════════════════════════════════════════════════════════════════════
104
+
105
+ CASCADE_AVAILABLE = False
106
+ HOLD = None
107
+
108
+ try:
109
+ from cascade import Hold
110
+ HOLD = Hold()
111
+ CASCADE_AVAILABLE = True
112
+ print("[CASCADE] Hold ready")
113
+ except ImportError:
114
+ print("[CASCADE] Hold not available")
115
+
116
+ try:
117
+ from cascade import CausationGraph
118
+ CAUSATION = CausationGraph()
119
+ print("[CASCADE] CausationGraph ready")
120
+ except:
121
+ CAUSATION = None
122
+
123
+ # ═══════════════════════════════════════════════════════════════════════════════
124
+ # DATA STRUCTURES
125
+ # ═══════════════════════════════════════════════════════════════════════════════
126
+
127
+ @dataclass
128
+ class MoveCandidate:
129
+ move: str
130
+ prob: float
131
+ value: float
132
+ from_sq: int
133
+ to_sq: int
134
+ is_capture: bool
135
+ is_check: bool
136
+ move_type: str # 'quiet', 'capture', 'check', 'castle'
137
+
138
+ # ═══════════════════════════════════════════════════════════════════════════════
139
+ # FEATURE EXTRACTION
140
+ # ═══════════════════════════════════════════════════════════════════════════════
141
+
142
+ PIECE_VALUES = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
143
+ chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
144
+
145
+ def extract_features(board: chess.Board) -> Dict[str, Any]:
146
+ """Extract position features for cascade display."""
147
+ # Material
148
+ white_mat = sum(len(board.pieces(pt, chess.WHITE)) * PIECE_VALUES[pt]
149
+ for pt in PIECE_VALUES)
150
+ black_mat = sum(len(board.pieces(pt, chess.BLACK)) * PIECE_VALUES[pt]
151
+ for pt in PIECE_VALUES)
152
+ material = white_mat - black_mat
153
+
154
+ # Center control
155
+ center = [chess.D4, chess.D5, chess.E4, chess.E5]
156
+ white_center = sum(1 for sq in center if board.is_attacked_by(chess.WHITE, sq))
157
+ black_center = sum(1 for sq in center if board.is_attacked_by(chess.BLACK, sq))
158
+
159
+ # King safety
160
+ wk = board.king(chess.WHITE)
161
+ bk = board.king(chess.BLACK)
162
+ white_king_attackers = len(board.attackers(chess.BLACK, wk)) if wk else 0
163
+ black_king_attackers = len(board.attackers(chess.WHITE, bk)) if bk else 0
164
+
165
+ # Mobility
166
+ turn = board.turn
167
+ board.turn = chess.WHITE
168
+ white_mobility = len(list(board.legal_moves))
169
+ board.turn = chess.BLACK
170
+ black_mobility = len(list(board.legal_moves))
171
+ board.turn = turn
172
+
173
+ # Game phase
174
+ total_pieces = len(board.piece_map())
175
+ if total_pieces > 28:
176
+ phase = 'opening'
177
+ elif total_pieces > 14:
178
+ phase = 'middlegame'
179
+ else:
180
+ phase = 'endgame'
181
+
182
+ return {
183
+ 'material': material,
184
+ 'center_control': (white_center - black_center) / 4.0,
185
+ 'white_king_danger': white_king_attackers,
186
+ 'black_king_danger': black_king_attackers,
187
+ 'white_mobility': white_mobility,
188
+ 'black_mobility': black_mobility,
189
+ 'phase': phase
190
+ }
191
+
192
+ # ═══════════════════════════════════════════════════════════════════════════════
193
+ # CANDIDATE GENERATION WITH CASCADE INTEGRATION
194
+ # ═══════════════════════════════════════════════════════════════════════════════
195
+
196
+ def get_candidates_with_hold(board: chess.Board, num=5) -> tuple:
197
+ """
198
+ Get move candidates from engine + build cascade Hold data.
199
+ Returns: (candidates, hold_data)
200
+ """
201
+ candidates = []
202
+ hold_data = {}
203
+
204
+ info = engine_analyse(board, chess.engine.Limit(depth=12), multipv=num)
205
+ if not info:
206
+ return [], {}
207
+
208
+ features = extract_features(board)
209
+ action_labels = []
210
+ raw_values = []
211
+
212
+ for pv in info:
213
+ move = pv['pv'][0]
214
+ score = pv.get('score', chess.engine.Cp(0))
215
+
216
+ if score.is_mate():
217
+ value = 1.0 if score.mate() > 0 else -1.0
218
+ else:
219
+ cp = score.relative.score(mate_score=10000)
220
+ value = max(-1, min(1, cp / 1000))
221
+
222
+ raw_values.append(value)
223
+ action_labels.append(move.uci())
224
+
225
+ move_type = 'quiet'
226
+ if board.is_capture(move):
227
+ move_type = 'capture'
228
+ elif board.gives_check(move):
229
+ move_type = 'check'
230
+ elif board.is_castling(move):
231
+ move_type = 'castle'
232
+
233
+ candidates.append(MoveCandidate(
234
+ move=move.uci(),
235
+ prob=0,
236
+ value=value,
237
+ from_sq=move.from_square,
238
+ to_sq=move.to_square,
239
+ is_capture=board.is_capture(move),
240
+ is_check=board.gives_check(move),
241
+ move_type=move_type
242
+ ))
243
+
244
+ # Softmax probabilities
245
+ values_arr = np.array(raw_values)
246
+ exp_vals = np.exp((values_arr - values_arr.max()) * 3)
247
+ probs = exp_vals / exp_vals.sum()
248
+
249
+ for i, c in enumerate(candidates):
250
+ c.prob = float(probs[i])
251
+
252
+ # Build imagination (predicted opponent responses)
253
+ imagination = {}
254
+ for i, c in enumerate(candidates[:3]):
255
+ test_board = board.copy()
256
+ test_board.push(chess.Move.from_uci(c.move))
257
+ if not test_board.is_game_over():
258
+ resp_info = engine_analyse(test_board, chess.engine.Limit(depth=8), multipv=1)
259
+ if resp_info:
260
+ resp_move = resp_info[0]['pv'][0].uci()
261
+ resp_score = resp_info[0].get('score', chess.engine.Cp(0))
262
+ if resp_score.is_mate():
263
+ resp_val = -1.0 if resp_score.mate() > 0 else 1.0
264
+ else:
265
+ cp = resp_score.relative.score(mate_score=10000)
266
+ resp_val = -max(-1, min(1, cp / 1000))
267
+ imagination[i] = {
268
+ 'response': resp_move,
269
+ 'value': resp_val,
270
+ 'line': f"{c.move} → {resp_move}"
271
+ }
272
+
273
+ # Build reasoning
274
+ reasoning = []
275
+ if candidates[0].is_capture:
276
+ reasoning.append("Top move captures material")
277
+ if candidates[0].is_check:
278
+ reasoning.append("Top move gives check")
279
+ if features['material'] > 2:
280
+ reasoning.append("White has material advantage (+{:.0f})".format(features['material']))
281
+ elif features['material'] < -2:
282
+ reasoning.append("Black has material advantage ({:.0f})".format(features['material']))
283
+ if features['center_control'] > 0.5:
284
+ reasoning.append("Strong center control")
285
+ if len(candidates) > 1 and abs(candidates[0].value - candidates[1].value) < 0.1:
286
+ reasoning.append("Multiple strong options")
287
+ if features['white_king_danger'] > 0:
288
+ reasoning.append("⚠ White king under attack")
289
+ if features['black_king_danger'] > 0:
290
+ reasoning.append("Black king vulnerable")
291
+
292
+ hold_data = {
293
+ 'action_probs': [c.prob for c in candidates],
294
+ 'action_labels': action_labels,
295
+ 'value': float(candidates[0].value) if candidates else 0,
296
+ 'features': features,
297
+ 'reasoning': reasoning,
298
+ 'imagination': imagination,
299
+ 'ai_choice': 0,
300
+ 'ai_confidence': float(probs[0]) if len(probs) > 0 else 0
301
+ }
302
+
303
+ return candidates, hold_data
304
+
305
+ # ═══════════════════════════════════════════════════════════════════════════════
306
+ # DASH APP
307
+ # ═══════════════════════════════════════════════════════════════════════════════
308
+
309
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
310
+
311
+ DARK_BG = '#0a0a0f'
312
+ PANEL_BG = '#0d0d12'
313
+ ACCENT = '#00FF88'
314
+ ACCENT_DIM = '#00FF8844'
315
+
316
+ app.layout = html.Div([
317
+ # Stores
318
+ dcc.Store(id='board-fen', data=chess.STARTING_FEN),
319
+ dcc.Store(id='candidates-store', data=[]),
320
+ dcc.Store(id='hold-data-store', data={}),
321
+ dcc.Store(id='selected-idx', data=0),
322
+ dcc.Store(id='is-held', data=False),
323
+ dcc.Store(id='move-history', data=[]),
324
+ dcc.Store(id='game-status', data='playing'), # playing, white_wins, black_wins, draw
325
+
326
+ # Header
327
+ html.Div([
328
+ html.H1("CASCADE-LATTICE Chess", style={
329
+ 'color': ACCENT, 'fontFamily': 'monospace', 'margin': '0',
330
+ 'fontSize': '24px', 'letterSpacing': '2px'
331
+ }),
332
+ html.Div("Human-AI Decision Support Demo", style={
333
+ 'color': '#666', 'fontFamily': 'monospace', 'fontSize': '12px'
334
+ })
335
+ ], style={'textAlign': 'center', 'padding': '15px', 'borderBottom': f'1px solid {ACCENT_DIM}'}),
336
+
337
+ # Main content
338
+ html.Div([
339
+ # LEFT: 3D Board + Controls
340
+ html.Div([
341
+ # Control buttons
342
+ html.Div([
343
+ html.Button("⏸ HOLD", id='btn-hold', style={
344
+ 'margin': '5px', 'padding': '10px 20px', 'fontSize': '14px',
345
+ 'fontFamily': 'monospace', 'fontWeight': 'bold',
346
+ 'backgroundColor': ACCENT_DIM, 'color': ACCENT,
347
+ 'border': f'2px solid {ACCENT}', 'borderRadius': '6px', 'cursor': 'pointer'
348
+ }),
349
+ html.Button("↻ RESET", id='btn-reset', style={
350
+ 'margin': '5px', 'padding': '10px 20px', 'fontSize': '14px',
351
+ 'fontFamily': 'monospace', 'fontWeight': 'bold',
352
+ 'backgroundColor': '#FF444422', 'color': '#FF4444',
353
+ 'border': '2px solid #FF4444', 'borderRadius': '6px', 'cursor': 'pointer'
354
+ })
355
+ ], style={'textAlign': 'center', 'padding': '10px'}),
356
+
357
+ # 3D iframe
358
+ html.Iframe(
359
+ id='chess-3d-iframe',
360
+ src='/assets/chess3d.html',
361
+ style={'width': '100%', 'height': '500px', 'border': 'none', 'borderRadius': '8px'}
362
+ ),
363
+ html.Div(id='scene-state', style={'display': 'none'}),
364
+
365
+ # Move selection (when HELD)
366
+ html.Div([
367
+ html.Div(id='move-buttons', style={'textAlign': 'center', 'padding': '10px'}),
368
+ html.Div([
369
+ html.Button("✓ COMMIT", id='btn-commit', style={
370
+ 'display': 'none', 'margin': '10px auto', 'padding': '15px 40px',
371
+ 'fontSize': '16px', 'fontFamily': 'monospace', 'fontWeight': 'bold',
372
+ 'backgroundColor': ACCENT_DIM, 'color': ACCENT,
373
+ 'border': f'2px solid {ACCENT}', 'borderRadius': '8px', 'cursor': 'pointer'
374
+ })
375
+ ], style={'textAlign': 'center'})
376
+ ]),
377
+
378
+ # Status
379
+ html.Div(id='game-status-display', style={
380
+ 'textAlign': 'center', 'padding': '10px', 'fontFamily': 'monospace',
381
+ 'color': '#888', 'fontSize': '14px'
382
+ })
383
+ ], style={'flex': '1', 'minWidth': '450px', 'padding': '10px'}),
384
+
385
+ # RIGHT: Cascade Info Panel
386
+ html.Div([
387
+ html.Div([
388
+ html.Span("◈ ", style={'color': ACCENT}),
389
+ html.Span("INFORMATIONAL WEALTH", style={'color': '#888'})
390
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '15px',
391
+ 'borderBottom': f'1px solid {ACCENT_DIM}', 'paddingBottom': '10px'}),
392
+
393
+ html.Div(id='wealth-panel', style={'fontFamily': 'monospace', 'fontSize': '12px'})
394
+ ], style={
395
+ 'flex': '1', 'minWidth': '350px', 'padding': '15px',
396
+ 'backgroundColor': PANEL_BG, 'borderRadius': '8px', 'margin': '10px',
397
+ 'border': f'1px solid {ACCENT_DIM}'
398
+ })
399
+ ], style={'display': 'flex', 'flexWrap': 'wrap', 'justifyContent': 'center'}),
400
+
401
+ # Move history
402
+ html.Div([
403
+ html.Div("MOVE HISTORY", style={'color': '#666', 'fontSize': '10px', 'marginBottom': '5px'}),
404
+ html.Div(id='history-display', style={'color': '#888', 'fontSize': '11px', 'maxHeight': '60px', 'overflow': 'auto'})
405
+ ], style={'fontFamily': 'monospace', 'textAlign': 'center', 'padding': '10px'})
406
+
407
+ ], style={'backgroundColor': DARK_BG, 'minHeight': '100vh', 'color': '#fff'})
408
+
409
+ # ═══════════════════════════════════════════════════════════════════════════════
410
+ # CALLBACKS
411
+ # ═══════════════════════════════════════════════════════════════════════════════
412
+
413
+ # HOLD button - get candidates
414
+ @callback(
415
+ Output('candidates-store', 'data'),
416
+ Output('hold-data-store', 'data'),
417
+ Output('is-held', 'data'),
418
+ Output('selected-idx', 'data'),
419
+ Input('btn-hold', 'n_clicks'),
420
+ State('board-fen', 'data'),
421
+ State('is-held', 'data'),
422
+ State('game-status', 'data'),
423
+ prevent_initial_call=True
424
+ )
425
+ def on_hold_click(n, fen, is_held, status):
426
+ if status != 'playing':
427
+ raise dash.exceptions.PreventUpdate
428
+
429
+ if is_held:
430
+ # Un-hold
431
+ return [], {}, False, 0
432
+ else:
433
+ # Hold - get candidates
434
+ board = chess.Board(fen)
435
+ if board.turn != chess.WHITE:
436
+ # Not white's turn
437
+ raise dash.exceptions.PreventUpdate
438
+
439
+ candidates, hold_data = get_candidates_with_hold(board)
440
+ if not candidates:
441
+ raise dash.exceptions.PreventUpdate
442
+
443
+ return [c.__dict__ for c in candidates], hold_data, True, 0
444
+
445
+ # RESET button
446
+ @callback(
447
+ Output('board-fen', 'data', allow_duplicate=True),
448
+ Output('candidates-store', 'data', allow_duplicate=True),
449
+ Output('hold-data-store', 'data', allow_duplicate=True),
450
+ Output('is-held', 'data', allow_duplicate=True),
451
+ Output('move-history', 'data', allow_duplicate=True),
452
+ Output('game-status', 'data', allow_duplicate=True),
453
+ Input('btn-reset', 'n_clicks'),
454
+ prevent_initial_call=True
455
+ )
456
+ def on_reset(n):
457
+ return chess.STARTING_FEN, [], {}, False, [], 'playing'
458
+
459
+ # Move button clicks - select candidate
460
+ @callback(
461
+ Output('selected-idx', 'data', allow_duplicate=True),
462
+ Input({'type': 'move-btn', 'index': dash.ALL}, 'n_clicks'),
463
+ prevent_initial_call=True
464
+ )
465
+ def on_move_select(clicks):
466
+ if not clicks or not any(clicks):
467
+ raise dash.exceptions.PreventUpdate
468
+
469
+ triggered = ctx.triggered_id
470
+ if triggered and 'index' in triggered:
471
+ return triggered['index']
472
+ raise dash.exceptions.PreventUpdate
473
+
474
+ # COMMIT - execute move + opponent responds
475
+ @callback(
476
+ Output('board-fen', 'data', allow_duplicate=True),
477
+ Output('candidates-store', 'data', allow_duplicate=True),
478
+ Output('hold-data-store', 'data', allow_duplicate=True),
479
+ Output('is-held', 'data', allow_duplicate=True),
480
+ Output('move-history', 'data', allow_duplicate=True),
481
+ Output('game-status', 'data', allow_duplicate=True),
482
+ Input('btn-commit', 'n_clicks'),
483
+ State('board-fen', 'data'),
484
+ State('candidates-store', 'data'),
485
+ State('selected-idx', 'data'),
486
+ State('move-history', 'data'),
487
+ prevent_initial_call=True
488
+ )
489
+ def on_commit(n, fen, candidates_data, selected_idx, history):
490
+ if not n or not candidates_data:
491
+ raise dash.exceptions.PreventUpdate
492
+
493
+ board = chess.Board(fen)
494
+ status = 'playing'
495
+
496
+ # 1. Execute White's move
497
+ if selected_idx < len(candidates_data):
498
+ move_uci = candidates_data[selected_idx]['move']
499
+ move = chess.Move.from_uci(move_uci)
500
+ board.push(move)
501
+ history = history + [move_uci]
502
+ print(f"[GAME] White plays: {move_uci}")
503
+
504
+ # Check game over after white
505
+ if board.is_game_over():
506
+ if board.is_checkmate():
507
+ status = 'white_wins'
508
+ else:
509
+ status = 'draw'
510
+ return board.fen(), [], {}, False, history, status
511
+
512
+ # 2. Engine plays Black
513
+ result = engine_play(board, chess.engine.Limit(depth=12))
514
+ if result and result.move:
515
+ board.push(result.move)
516
+ history = history + [result.move.uci()]
517
+ print(f"[GAME] Black plays: {result.move.uci()}")
518
+
519
+ # Check game over after black
520
+ if board.is_game_over():
521
+ if board.is_checkmate():
522
+ status = 'black_wins'
523
+ else:
524
+ status = 'draw'
525
+
526
+ return board.fen(), [], {}, False, history, status
527
+
528
+ # Show/hide COMMIT button
529
+ @callback(
530
+ Output('btn-commit', 'style'),
531
+ Input('is-held', 'data')
532
+ )
533
+ def toggle_commit(is_held):
534
+ base = {
535
+ 'margin': '10px auto', 'padding': '15px 40px',
536
+ 'fontSize': '16px', 'fontFamily': 'monospace', 'fontWeight': 'bold',
537
+ 'backgroundColor': ACCENT_DIM, 'color': ACCENT,
538
+ 'border': f'2px solid {ACCENT}', 'borderRadius': '8px', 'cursor': 'pointer'
539
+ }
540
+ base['display'] = 'block' if is_held else 'none'
541
+ return base
542
+
543
+ # Move buttons
544
+ @callback(
545
+ Output('move-buttons', 'children'),
546
+ Input('candidates-store', 'data'),
547
+ Input('selected-idx', 'data'),
548
+ Input('is-held', 'data')
549
+ )
550
+ def render_move_buttons(candidates_data, selected_idx, is_held):
551
+ if not is_held or not candidates_data:
552
+ return []
553
+
554
+ buttons = []
555
+ for i, c in enumerate(candidates_data):
556
+ is_selected = (i == selected_idx)
557
+ style = {
558
+ 'margin': '5px', 'padding': '10px 15px',
559
+ 'fontSize': '13px', 'fontFamily': 'monospace', 'fontWeight': 'bold',
560
+ 'borderRadius': '6px', 'cursor': 'pointer',
561
+ 'transition': 'all 0.2s'
562
+ }
563
+
564
+ if is_selected:
565
+ style['backgroundColor'] = ACCENT
566
+ style['color'] = '#000'
567
+ style['border'] = f'2px solid {ACCENT}'
568
+ style['boxShadow'] = f'0 0 15px {ACCENT}'
569
+ else:
570
+ style['backgroundColor'] = '#1a1a24'
571
+ style['color'] = '#888'
572
+ style['border'] = '2px solid #333'
573
+
574
+ prob_pct = c['prob'] * 100
575
+ label = f"{c['move']} ({prob_pct:.0f}%)"
576
+
577
+ buttons.append(html.Button(
578
+ label,
579
+ id={'type': 'move-btn', 'index': i},
580
+ style=style
581
+ ))
582
+
583
+ return buttons
584
+
585
+ # Wealth panel
586
+ @callback(
587
+ Output('wealth-panel', 'children'),
588
+ Input('hold-data-store', 'data'),
589
+ Input('selected-idx', 'data'),
590
+ Input('is-held', 'data')
591
+ )
592
+ def render_wealth(hold_data, selected_idx, is_held):
593
+ if not is_held or not hold_data:
594
+ return html.Div("Press HOLD to analyze position", style={'color': '#555', 'fontStyle': 'italic'})
595
+
596
+ features = hold_data.get('features', {})
597
+ reasoning = hold_data.get('reasoning', [])
598
+ imagination = hold_data.get('imagination', {})
599
+ probs = hold_data.get('action_probs', [])
600
+ labels = hold_data.get('action_labels', [])
601
+
602
+ sections = []
603
+
604
+ # Position Features
605
+ sections.append(html.Div([
606
+ html.Div("▸ POSITION FEATURES", style={'color': ACCENT, 'marginBottom': '8px'}),
607
+ html.Div([
608
+ html.Div(f"Material: {features.get('material', 0):+.1f}", style={'color': '#aaa'}),
609
+ html.Div(f"Center: {features.get('center_control', 0):+.2f}", style={'color': '#aaa'}),
610
+ html.Div(f"Phase: {features.get('phase', '?')}", style={'color': '#aaa'}),
611
+ html.Div(f"W mobility: {features.get('white_mobility', 0)}", style={'color': '#aaa'}),
612
+ html.Div(f"B mobility: {features.get('black_mobility', 0)}", style={'color': '#aaa'}),
613
+ ], style={'paddingLeft': '15px', 'marginBottom': '15px'})
614
+ ]))
615
+
616
+ # AI Reasoning
617
+ if reasoning:
618
+ sections.append(html.Div([
619
+ html.Div("▸ AI REASONING", style={'color': ACCENT, 'marginBottom': '8px'}),
620
+ html.Div([
621
+ html.Div(f"• {r}", style={'color': '#aaa', 'marginBottom': '3px'}) for r in reasoning
622
+ ], style={'paddingLeft': '15px', 'marginBottom': '15px'})
623
+ ]))
624
+
625
+ # Imagination (predicted lines)
626
+ if imagination:
627
+ sections.append(html.Div([
628
+ html.Div("▸ IMAGINATION (predicted responses)", style={'color': ACCENT, 'marginBottom': '8px'}),
629
+ html.Div([
630
+ html.Div([
631
+ html.Span(f"#{int(k)+1}: ", style={'color': '#666'}),
632
+ html.Span(img['line'], style={'color': '#aaa'}),
633
+ html.Span(f" ({img['value']:+.2f})", style={'color': '#666'})
634
+ ], style={'marginBottom': '3px'})
635
+ for k, img in imagination.items()
636
+ ], style={'paddingLeft': '15px', 'marginBottom': '15px'})
637
+ ]))
638
+
639
+ # Selected move confidence
640
+ if selected_idx < len(probs):
641
+ conf = probs[selected_idx] * 100
642
+ move = labels[selected_idx] if selected_idx < len(labels) else '?'
643
+ sections.append(html.Div([
644
+ html.Div("▸ SELECTED MOVE", style={'color': ACCENT, 'marginBottom': '8px'}),
645
+ html.Div([
646
+ html.Span(move, style={'color': '#fff', 'fontSize': '18px', 'fontWeight': 'bold'}),
647
+ html.Span(f" {conf:.1f}% confidence", style={'color': '#888'})
648
+ ], style={'paddingLeft': '15px'})
649
+ ]))
650
+
651
+ return sections
652
+
653
+ # Scene state for Three.js
654
+ @callback(
655
+ Output('scene-state', 'children'),
656
+ Input('board-fen', 'data'),
657
+ Input('candidates-store', 'data'),
658
+ Input('selected-idx', 'data')
659
+ )
660
+ def update_scene(fen, candidates_data, selected_idx):
661
+ candidates = []
662
+ if candidates_data:
663
+ for i, c in enumerate(candidates_data):
664
+ candidates.append({
665
+ 'from_sq': c['from_sq'],
666
+ 'to_sq': c['to_sq'],
667
+ 'prob': c['prob'],
668
+ 'selected': (i == selected_idx)
669
+ })
670
+
671
+ state = {'fen': fen, 'candidates': candidates}
672
+ return json.dumps(state)
673
+
674
+ # Game status display
675
+ @callback(
676
+ Output('game-status-display', 'children'),
677
+ Input('game-status', 'data'),
678
+ Input('board-fen', 'data')
679
+ )
680
+ def show_status(status, fen):
681
+ board = chess.Board(fen)
682
+ turn = "White" if board.turn == chess.WHITE else "Black"
683
+
684
+ if status == 'white_wins':
685
+ return html.Span("✓ WHITE WINS!", style={'color': ACCENT, 'fontWeight': 'bold'})
686
+ elif status == 'black_wins':
687
+ return html.Span("✗ BLACK WINS", style={'color': '#FF4444', 'fontWeight': 'bold'})
688
+ elif status == 'draw':
689
+ return html.Span("½ DRAW", style={'color': '#888'})
690
+ else:
691
+ return f"{turn} to move • Press HOLD to analyze"
692
+
693
+ # History display
694
+ @callback(
695
+ Output('history-display', 'children'),
696
+ Input('move-history', 'data')
697
+ )
698
+ def show_history(history):
699
+ if not history:
700
+ return "Game start"
701
+
702
+ moves = []
703
+ for i in range(0, len(history), 2):
704
+ num = i // 2 + 1
705
+ white = history[i]
706
+ black = history[i + 1] if i + 1 < len(history) else "..."
707
+ moves.append(f"{num}. {white} {black}")
708
+
709
+ return " ".join(moves)
710
+
711
+ # ═══════════════════════════════════════════════════════════════════════════════
712
+ # MAIN
713
+ # ═══════════════════════════════════════════════════════════════════════════════
714
+
715
+ if __name__ == '__main__':
716
+ print("\n" + "=" * 50)
717
+ print("CASCADE-LATTICE Chess (Clean Build)")
718
+ print("=" * 50)
719
+ print("Open: http://127.0.0.1:8050")
720
+ print("=" * 50 + "\n")
721
+
722
+ app.run(debug=False, host='127.0.0.1', port=8050)
src/app_dash.py ADDED
@@ -0,0 +1,1540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CASCADE-LATTICE Chess - Dash Version
3
+ ====================================
4
+ Proper callback-based visualization that doesn't shit itself.
5
+ """
6
+
7
+ import dash
8
+ from dash import dcc, html, callback, Input, Output, State
9
+ import plotly.graph_objects as go
10
+ import chess
11
+ import chess.engine
12
+ import numpy as np
13
+ import asyncio
14
+ import platform
15
+ from pathlib import Path
16
+ import shutil
17
+ from dataclasses import dataclass
18
+ from typing import List, Optional
19
+
20
+ # ═══════════════════════════════════════════════════════════════════════════════
21
+ # STOCKFISH SETUP
22
+ # ═══════════════════════════════════════════════════════════════════════════════
23
+
24
+ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
25
+ LOCAL_STOCKFISH = PROJECT_ROOT / "stockfish" / "stockfish-windows-x86-64-avx2.exe"
26
+
27
+ if LOCAL_STOCKFISH.exists():
28
+ STOCKFISH_PATH = str(LOCAL_STOCKFISH)
29
+ elif shutil.which("stockfish"):
30
+ STOCKFISH_PATH = shutil.which("stockfish")
31
+ else:
32
+ STOCKFISH_PATH = "/usr/games/stockfish"
33
+
34
+ print(f"[STOCKFISH] {STOCKFISH_PATH}")
35
+
36
+ # Fix Windows asyncio for chess engine
37
+ if platform.system() == 'Windows':
38
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
39
+
40
+ # Global engine (initialized once)
41
+ ENGINE = None
42
+ try:
43
+ ENGINE = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH)
44
+ print("[ENGINE] Loaded OK")
45
+ except Exception as e:
46
+ print(f"[ENGINE] Failed: {e}")
47
+
48
+ # ═══════════════════════════════════════════════════════════════════════════════
49
+ # CASCADE-LATTICE
50
+ # ═══════════════════════════════════════════════════════════════════════════════
51
+
52
+ CASCADE_AVAILABLE = False
53
+ HOLD = None
54
+ CAUSATION = None
55
+ TRACER = None
56
+
57
+ try:
58
+ from cascade import Hold
59
+ HOLD = Hold()
60
+ CASCADE_AVAILABLE = True
61
+ print("[CASCADE] Hold ready")
62
+ except ImportError:
63
+ print("[CASCADE] Hold not available")
64
+
65
+ try:
66
+ from cascade import CausationGraph
67
+ CAUSATION = CausationGraph()
68
+ print("[CASCADE] CausationGraph ready")
69
+ except:
70
+ pass
71
+
72
+ try:
73
+ from cascade import Tracer
74
+ if CAUSATION:
75
+ TRACER = Tracer(CAUSATION)
76
+ print("[CASCADE] Tracer ready")
77
+ except:
78
+ pass
79
+
80
+ # ═══════════════════════════════════════════════════════════════════════════════
81
+ # VISUAL THEME
82
+ # ═══════════════════════════════════════════════════════════════════════════════
83
+
84
+ # Board - rich wood tones
85
+ BOARD_LIGHT = '#D4A574' # Maple
86
+ BOARD_DARK = '#8B5A2B' # Walnut
87
+ BOARD_EDGE = '#4A3520' # Dark frame
88
+
89
+ # Pieces - polished look
90
+ WHITE_PIECE = '#FFFEF0' # Ivory
91
+ WHITE_SHADOW = '#C0B090' # Ivory shadow
92
+ BLACK_PIECE = '#2A2A2A' # Ebony
93
+ BLACK_SHADOW = '#151515' # Ebony shadow
94
+
95
+ # Accents
96
+ GOLD = '#FFD700'
97
+ CRIMSON = '#DC143C'
98
+ CYAN = '#00FFD4'
99
+ MAGENTA = '#FF00AA'
100
+
101
+ BG_COLOR = '#0a0a0f'
102
+ BG_GRADIENT = '#12121a'
103
+
104
+ # Piece geometry - height and multiple size layers for 3D effect
105
+ PIECE_CONFIG = {
106
+ chess.PAWN: {'h': 0.5, 'base': 8, 'mid': 6, 'top': 4, 'symbol': '♟'},
107
+ chess.KNIGHT: {'h': 0.8, 'base': 10, 'mid': 8, 'top': 6, 'symbol': '♞'},
108
+ chess.BISHOP: {'h': 0.9, 'base': 10, 'mid': 7, 'top': 5, 'symbol': '♝'},
109
+ chess.ROOK: {'h': 0.7, 'base': 10, 'mid': 9, 'top': 8, 'symbol': '♜'},
110
+ chess.QUEEN: {'h': 1.1, 'base': 12, 'mid': 9, 'top': 6, 'symbol': '♛'},
111
+ chess.KING: {'h': 1.2, 'base': 12, 'mid': 8, 'top': 5, 'symbol': '♚'},
112
+ }
113
+
114
+ # ═══════════════════════════════════════════════════════════════════════════════
115
+ # DATA
116
+ # ═══════════════════════════════════════════════════════════════════════════════
117
+
118
+ @dataclass
119
+ class MoveCandidate:
120
+ move: str
121
+ prob: float
122
+ value: float
123
+ from_sq: int
124
+ to_sq: int
125
+ is_capture: bool = False
126
+ is_check: bool = False
127
+ move_type: str = 'quiet'
128
+
129
+ @dataclass
130
+ class CascadeTrace:
131
+ """Represents a cascade-lattice inference trace."""
132
+ step: int
133
+ operation: str
134
+ inputs: List[str]
135
+ output: str
136
+ duration_ms: float
137
+ confidence: float
138
+
139
+ # Global trace storage
140
+ TRACE_LOG: List[dict] = []
141
+ DECISION_TREE: List[dict] = []
142
+
143
+ # ═══════════════════════════════════════════════════════════════════════════════
144
+ # 3D VISUALIZATION
145
+ # ═══════════════════════════════════════════════════════════════════════════════
146
+
147
+ def square_to_3d(square: int):
148
+ file = square % 8
149
+ rank = square // 8
150
+ return (file - 3.5, rank - 3.5, 0)
151
+
152
+ def create_board_traces():
153
+ """Create 3D board with mesh tiles."""
154
+ traces = []
155
+ tile_size = 0.48
156
+ tile_height = 0.08
157
+
158
+ for rank in range(8):
159
+ for file in range(8):
160
+ cx, cy = file - 3.5, rank - 3.5
161
+ is_light = (rank + file) % 2 == 1
162
+ color = BOARD_LIGHT if is_light else BOARD_DARK
163
+
164
+ # Top surface of tile
165
+ traces.append(go.Mesh3d(
166
+ x=[cx-tile_size, cx+tile_size, cx+tile_size, cx-tile_size],
167
+ y=[cy-tile_size, cy-tile_size, cy+tile_size, cy+tile_size],
168
+ z=[tile_height, tile_height, tile_height, tile_height],
169
+ i=[0, 0], j=[1, 2], k=[2, 3],
170
+ color=color, opacity=0.95, flatshading=True,
171
+ hoverinfo='skip', showlegend=False
172
+ ))
173
+
174
+ # Board frame/border
175
+ border = 4.3
176
+ frame_z = tile_height / 2
177
+ traces.append(go.Scatter3d(
178
+ x=[-border, border, border, -border, -border],
179
+ y=[-border, -border, border, border, -border],
180
+ z=[frame_z]*5, mode='lines',
181
+ line=dict(color=GOLD, width=3),
182
+ hoverinfo='skip', showlegend=False
183
+ ))
184
+
185
+ # File labels (a-h)
186
+ for i, label in enumerate('abcdefgh'):
187
+ traces.append(go.Scatter3d(
188
+ x=[i-3.5], y=[-4.6], z=[0.1], mode='text', text=[label],
189
+ textfont=dict(size=11, color='#666'), showlegend=False
190
+ ))
191
+ # Rank labels (1-8)
192
+ for i in range(8):
193
+ traces.append(go.Scatter3d(
194
+ x=[-4.6], y=[i-3.5], z=[0.1], mode='text', text=[str(i+1)],
195
+ textfont=dict(size=11, color='#666'), showlegend=False
196
+ ))
197
+
198
+ return traces
199
+
200
+ def create_piece_traces(board: chess.Board):
201
+ """Create holographic crystal-style chess pieces using rotated point silhouettes."""
202
+ traces = []
203
+
204
+ # Define piece silhouettes as point patterns (normalized 0-1 range)
205
+ # These trace the outline/shape of each piece type
206
+ PIECE_SILHOUETTES = {
207
+ chess.PAWN: [
208
+ (0.5, 0.0), (0.3, 0.1), (0.25, 0.2), (0.3, 0.3), (0.35, 0.4),
209
+ (0.4, 0.5), (0.35, 0.6), (0.3, 0.7), (0.4, 0.8), (0.5, 0.9),
210
+ (0.6, 0.8), (0.7, 0.7), (0.65, 0.6), (0.6, 0.5), (0.65, 0.4),
211
+ (0.7, 0.3), (0.75, 0.2), (0.7, 0.1), (0.5, 0.0)
212
+ ],
213
+ chess.ROOK: [
214
+ (0.25, 0.0), (0.25, 0.15), (0.35, 0.15), (0.35, 0.25), (0.25, 0.25),
215
+ (0.25, 0.85), (0.35, 0.9), (0.35, 1.0), (0.45, 1.0), (0.45, 0.9),
216
+ (0.55, 0.9), (0.55, 1.0), (0.65, 1.0), (0.65, 0.9), (0.75, 0.85),
217
+ (0.75, 0.25), (0.65, 0.25), (0.65, 0.15), (0.75, 0.15), (0.75, 0.0)
218
+ ],
219
+ chess.KNIGHT: [
220
+ (0.25, 0.0), (0.25, 0.2), (0.3, 0.35), (0.25, 0.5), (0.3, 0.6),
221
+ (0.35, 0.7), (0.3, 0.8), (0.4, 0.9), (0.5, 1.0), (0.6, 0.95),
222
+ (0.7, 0.85), (0.75, 0.7), (0.7, 0.55), (0.65, 0.4), (0.7, 0.25),
223
+ (0.75, 0.15), (0.75, 0.0)
224
+ ],
225
+ chess.BISHOP: [
226
+ (0.3, 0.0), (0.25, 0.15), (0.3, 0.3), (0.35, 0.45), (0.4, 0.6),
227
+ (0.35, 0.7), (0.4, 0.8), (0.5, 0.95), (0.6, 0.8), (0.65, 0.7),
228
+ (0.6, 0.6), (0.65, 0.45), (0.7, 0.3), (0.75, 0.15), (0.7, 0.0)
229
+ ],
230
+ chess.QUEEN: [
231
+ (0.25, 0.0), (0.2, 0.15), (0.25, 0.3), (0.3, 0.5), (0.25, 0.65),
232
+ (0.3, 0.75), (0.2, 0.85), (0.35, 0.9), (0.5, 1.0), (0.65, 0.9),
233
+ (0.8, 0.85), (0.7, 0.75), (0.75, 0.65), (0.7, 0.5), (0.75, 0.3),
234
+ (0.8, 0.15), (0.75, 0.0)
235
+ ],
236
+ chess.KING: [
237
+ (0.25, 0.0), (0.2, 0.15), (0.25, 0.35), (0.3, 0.55), (0.35, 0.7),
238
+ (0.3, 0.8), (0.45, 0.85), (0.45, 0.92), (0.4, 0.92), (0.4, 0.97),
239
+ (0.5, 1.0), (0.6, 0.97), (0.6, 0.92), (0.55, 0.92), (0.55, 0.85),
240
+ (0.7, 0.8), (0.65, 0.7), (0.7, 0.55), (0.75, 0.35), (0.8, 0.15), (0.75, 0.0)
241
+ ],
242
+ }
243
+
244
+ PIECE_HEIGHT = {
245
+ chess.PAWN: 0.4,
246
+ chess.KNIGHT: 0.55,
247
+ chess.BISHOP: 0.55,
248
+ chess.ROOK: 0.5,
249
+ chess.QUEEN: 0.65,
250
+ chess.KING: 0.7,
251
+ }
252
+
253
+ NUM_ROTATIONS = 6 # How many rotated planes per piece
254
+
255
+ for sq in chess.SQUARES:
256
+ piece = board.piece_at(sq)
257
+ if not piece:
258
+ continue
259
+
260
+ cx, cy, _ = square_to_3d(sq)
261
+ silhouette = PIECE_SILHOUETTES[piece.piece_type]
262
+ height = PIECE_HEIGHT[piece.piece_type]
263
+ color = '#E8E8E8' if piece.color == chess.WHITE else '#404040'
264
+
265
+ # Create rotated copies of the silhouette
266
+ for rot in range(NUM_ROTATIONS):
267
+ angle = (rot / NUM_ROTATIONS) * np.pi # 0 to 180 degrees
268
+
269
+ xs, ys, zs = [], [], []
270
+ for px, pz in silhouette:
271
+ # Offset from center (-0.5 to +0.5 range)
272
+ local_x = (px - 0.5) * 0.4 # Scale to fit in square
273
+ local_z = pz * height + 0.1 # Height above board
274
+
275
+ # Rotate around Y (vertical) axis
276
+ rotated_x = local_x * np.cos(angle)
277
+ rotated_y = local_x * np.sin(angle)
278
+
279
+ xs.append(cx + rotated_x)
280
+ ys.append(cy + rotated_y)
281
+ zs.append(local_z)
282
+
283
+ traces.append(go.Scatter3d(
284
+ x=xs, y=ys, z=zs,
285
+ mode='lines',
286
+ line=dict(color=color, width=2),
287
+ opacity=0.7,
288
+ hoverinfo='skip',
289
+ showlegend=False
290
+ ))
291
+
292
+ return traces
293
+
294
+ return traces
295
+
296
+ def create_arc(from_sq, to_sq, is_white, num_points=40):
297
+ """Create smooth arc trajectory with proper curve."""
298
+ x1, y1, _ = square_to_3d(from_sq)
299
+ x2, y2, _ = square_to_3d(to_sq)
300
+ dist = np.sqrt((x2-x1)**2 + (y2-y1)**2)
301
+ # Cap height to stay within z bounds - short moves get small arcs, long moves capped
302
+ height = min(1.8, max(0.8, dist * 0.35))
303
+
304
+ t = np.linspace(0, 1, num_points)
305
+ x = x1 + (x2 - x1) * t
306
+ y = y1 + (y2 - y1) * t
307
+ # Arc always goes UP (positive z) - offset from board surface
308
+ z = 0.3 + height * np.sin(np.pi * t)
309
+ return x, y, z
310
+
311
+ def create_move_arcs(candidates: List[MoveCandidate], is_white: bool):
312
+ """Create glowing move arc traces."""
313
+ traces = []
314
+
315
+ # Color gradient based on rank
316
+ if is_white:
317
+ colors = [CYAN, '#00CCAA', '#009988', '#006666', '#004444']
318
+ else:
319
+ colors = [MAGENTA, '#CC0088', '#990066', '#660044', '#440022']
320
+
321
+ for i, cand in enumerate(candidates):
322
+ x, y, z = create_arc(cand.from_sq, cand.to_sq, is_white)
323
+ color = colors[min(i, len(colors)-1)]
324
+ width = max(4, cand.prob * 15)
325
+ opacity = max(0.5, cand.prob * 1.2)
326
+
327
+ # Outer glow (wide, transparent)
328
+ traces.append(go.Scatter3d(
329
+ x=x, y=y, z=z, mode='lines',
330
+ line=dict(color=color, width=width+6),
331
+ opacity=opacity*0.2, hoverinfo='skip', showlegend=False
332
+ ))
333
+ # Mid glow
334
+ traces.append(go.Scatter3d(
335
+ x=x, y=y, z=z, mode='lines',
336
+ line=dict(color=color, width=width+2),
337
+ opacity=opacity*0.4, hoverinfo='skip', showlegend=False
338
+ ))
339
+ # Core line
340
+ traces.append(go.Scatter3d(
341
+ x=x, y=y, z=z, mode='lines',
342
+ line=dict(color=color, width=width),
343
+ opacity=opacity,
344
+ name=f"{cand.move} ({cand.prob*100:.0f}%)",
345
+ hovertemplate=f"<b>{cand.move}</b><br>Probability: {cand.prob*100:.1f}%<br>Eval: {cand.value:+.2f}<extra></extra>"
346
+ ))
347
+
348
+ # Start point (piece origin)
349
+ traces.append(go.Scatter3d(
350
+ x=[x[0]], y=[y[0]], z=[z[0]], mode='markers',
351
+ marker=dict(size=5, color=color, symbol='circle', opacity=opacity),
352
+ hoverinfo='skip', showlegend=False
353
+ ))
354
+
355
+ # End point (destination) - larger, prominent
356
+ traces.append(go.Scatter3d(
357
+ x=[x[-1]], y=[y[-1]], z=[z[-1]], mode='markers',
358
+ marker=dict(size=8, color=color, symbol='diamond',
359
+ line=dict(color='white', width=1)),
360
+ hoverinfo='skip', showlegend=False
361
+ ))
362
+
363
+ # Drop line to board (shows landing square)
364
+ x2, y2, _ = square_to_3d(cand.to_sq)
365
+ traces.append(go.Scatter3d(
366
+ x=[x2, x2], y=[y2, y2], z=[z[-1], 0.15],
367
+ mode='lines', line=dict(color=color, width=2, dash='dot'),
368
+ opacity=opacity*0.5, hoverinfo='skip', showlegend=False
369
+ ))
370
+
371
+ return traces
372
+
373
+ # ═══════════════════════════════════════════════════════════════════════════════
374
+ # TACTICAL VISUALIZATION
375
+ # ═══════════════════════════════════════════════════════════════════════════════
376
+
377
+ def get_piece_attacks(board: chess.Board, square: int) -> List[int]:
378
+ """Get squares attacked by piece at given square."""
379
+ piece = board.piece_at(square)
380
+ if not piece:
381
+ return []
382
+ return list(board.attacks(square))
383
+
384
+ def get_attackers(board: chess.Board, square: int, color: bool) -> List[int]:
385
+ """Get pieces of given color attacking a square."""
386
+ return list(board.attackers(color, square))
387
+
388
+ def create_threat_beams(board: chess.Board):
389
+ """Create downward beams showing attack vectors - underneath the board."""
390
+ traces = []
391
+
392
+ for sq in chess.SQUARES:
393
+ piece = board.piece_at(sq)
394
+ if not piece:
395
+ continue
396
+
397
+ attacks = get_piece_attacks(board, sq)
398
+ if not attacks:
399
+ continue
400
+
401
+ px, py, _ = square_to_3d(sq)
402
+ color = 'rgba(255,215,0,0.15)' if piece.color == chess.WHITE else 'rgba(220,20,60,0.15)'
403
+
404
+ for target_sq in attacks:
405
+ tx, ty, _ = square_to_3d(target_sq)
406
+ # Beam goes from piece position DOWN through board to show threat zone
407
+ traces.append(go.Scatter3d(
408
+ x=[px, tx], y=[py, ty], z=[-0.05, -0.2],
409
+ mode='lines', line=dict(color=color, width=1),
410
+ hoverinfo='skip', showlegend=False
411
+ ))
412
+
413
+ return traces
414
+
415
+ def create_tension_lines(board: chess.Board):
416
+ """Create lines between pieces that threaten each other (mutual tension)."""
417
+ traces = []
418
+ tension_pairs = set()
419
+
420
+ for sq in chess.SQUARES:
421
+ piece = board.piece_at(sq)
422
+ if not piece:
423
+ continue
424
+
425
+ # Check if this piece attacks any enemy pieces
426
+ for target_sq in board.attacks(sq):
427
+ target_piece = board.piece_at(target_sq)
428
+ if target_piece and target_piece.color != piece.color:
429
+ # Create unique pair key
430
+ pair = tuple(sorted([sq, target_sq]))
431
+ if pair not in tension_pairs:
432
+ tension_pairs.add(pair)
433
+
434
+ x1, y1, _ = square_to_3d(sq)
435
+ x2, y2, _ = square_to_3d(target_sq)
436
+
437
+ # Check if it's mutual (both threaten each other)
438
+ mutual = target_sq in board.attacks(sq) and sq in board.attacks(target_sq)
439
+
440
+ if mutual:
441
+ color = MAGENTA
442
+ width = 3
443
+ opacity = 0.6
444
+ else:
445
+ color = CRIMSON if piece.color == chess.BLACK else GOLD
446
+ width = 2
447
+ opacity = 0.4
448
+
449
+ # Tension line slightly above board
450
+ traces.append(go.Scatter3d(
451
+ x=[x1, x2], y=[y1, y2], z=[0.8, 0.8],
452
+ mode='lines', line=dict(color=color, width=width),
453
+ opacity=opacity, hoverinfo='skip', showlegend=False
454
+ ))
455
+
456
+ return traces
457
+
458
+ def create_hanging_indicators(board: chess.Board):
459
+ """Mark pieces that are hanging (attacked but not defended)."""
460
+ traces = []
461
+
462
+ for sq in chess.SQUARES:
463
+ piece = board.piece_at(sq)
464
+ if not piece:
465
+ continue
466
+
467
+ # Count attackers and defenders
468
+ enemy_color = not piece.color
469
+ attackers = len(board.attackers(enemy_color, sq))
470
+ defenders = len(board.attackers(piece.color, sq))
471
+
472
+ if attackers > 0 and defenders == 0:
473
+ # Hanging! Draw danger indicator
474
+ x, y, _ = square_to_3d(sq)
475
+
476
+ # Pulsing ring under the piece
477
+ theta = np.linspace(0, 2*np.pi, 20)
478
+ ring_x = x + 0.3 * np.cos(theta)
479
+ ring_y = y + 0.3 * np.sin(theta)
480
+ ring_z = [0.05] * len(theta)
481
+
482
+ traces.append(go.Scatter3d(
483
+ x=ring_x, y=ring_y, z=ring_z,
484
+ mode='lines', line=dict(color=CRIMSON, width=4),
485
+ opacity=0.8, hoverinfo='skip', showlegend=False
486
+ ))
487
+
488
+ # Exclamation point above
489
+ traces.append(go.Scatter3d(
490
+ x=[x], y=[y], z=[1.0],
491
+ mode='text', text=['⚠'],
492
+ textfont=dict(size=16, color=CRIMSON),
493
+ hoverinfo='skip', showlegend=False
494
+ ))
495
+
496
+ return traces
497
+
498
+ def create_king_safety(board: chess.Board):
499
+ """Create safety dome around kings showing their safety status."""
500
+ traces = []
501
+
502
+ for color in [chess.WHITE, chess.BLACK]:
503
+ king_sq = board.king(color)
504
+ if king_sq is None:
505
+ continue
506
+
507
+ kx, ky, _ = square_to_3d(king_sq)
508
+
509
+ # Count attackers around king zone
510
+ danger_level = 0
511
+ for sq in chess.SQUARES:
512
+ file_dist = abs(chess.square_file(sq) - chess.square_file(king_sq))
513
+ rank_dist = abs(chess.square_rank(sq) - chess.square_rank(king_sq))
514
+ if file_dist <= 1 and rank_dist <= 1: # King zone
515
+ enemy_attackers = len(board.attackers(not color, sq))
516
+ danger_level += enemy_attackers
517
+
518
+ # Dome color based on safety
519
+ if board.is_check() and board.turn == color:
520
+ dome_color = CRIMSON
521
+ opacity = 0.5
522
+ elif danger_level > 5:
523
+ dome_color = '#FF6600' # Orange - danger
524
+ opacity = 0.3
525
+ elif danger_level > 2:
526
+ dome_color = GOLD # Yellow - caution
527
+ opacity = 0.2
528
+ else:
529
+ dome_color = '#00FF00' # Green - safe
530
+ opacity = 0.15
531
+
532
+ # Draw dome as circles at different heights
533
+ for h in [0.3, 0.5, 0.7]:
534
+ radius = 0.6 * (1 - h/1.5) # Smaller at top
535
+ theta = np.linspace(0, 2*np.pi, 24)
536
+ dome_x = kx + radius * np.cos(theta)
537
+ dome_y = ky + radius * np.sin(theta)
538
+ dome_z = [h] * len(theta)
539
+
540
+ traces.append(go.Scatter3d(
541
+ x=dome_x, y=dome_y, z=dome_z,
542
+ mode='lines', line=dict(color=dome_color, width=2),
543
+ opacity=opacity, hoverinfo='skip', showlegend=False
544
+ ))
545
+
546
+ return traces
547
+
548
+ def create_candidate_origins(board: chess.Board, candidates: List[MoveCandidate]):
549
+ """Highlight pieces that are generating the top candidate moves."""
550
+ traces = []
551
+ if not candidates:
552
+ return traces
553
+
554
+ # Collect unique origin squares from candidates
555
+ origins = {}
556
+ for i, cand in enumerate(candidates):
557
+ sq = cand.from_sq
558
+ if sq not in origins:
559
+ origins[sq] = i # Store best rank
560
+
561
+ for sq, rank in origins.items():
562
+ x, y, _ = square_to_3d(sq)
563
+
564
+ # Intensity based on rank (top candidate = brightest)
565
+ intensity = 1.0 - (rank * 0.15)
566
+ color = f'rgba(0,255,212,{intensity * 0.6})' # Cyan glow
567
+
568
+ # Glow ring around the piece
569
+ theta = np.linspace(0, 2*np.pi, 20)
570
+ ring_x = x + 0.35 * np.cos(theta)
571
+ ring_y = y + 0.35 * np.sin(theta)
572
+ ring_z = [0.08] * len(theta)
573
+
574
+ traces.append(go.Scatter3d(
575
+ x=ring_x, y=ring_y, z=ring_z,
576
+ mode='lines', line=dict(color=color, width=3 + (5-rank)),
577
+ hoverinfo='skip', showlegend=False
578
+ ))
579
+
580
+ return traces
581
+
582
+ # ═══════════════════════════════════════════════════════════════════════════════
583
+ # STATE-SPACE LATTICE GRAPH
584
+ # ═══════════════════════════════════════════════════════════════════════════════
585
+
586
+ def create_state_lattice(move_history: List[str], candidates: List[MoveCandidate] = None):
587
+ """
588
+ Create a state-space graph visualization above/below the board.
589
+ Each move node is positioned ABOVE its destination square.
590
+ White moves above board, black moves below.
591
+ Edges trace the flow of the game through space.
592
+ """
593
+ traces = []
594
+ if not move_history or len(move_history) < 1:
595
+ return traces
596
+
597
+ # Z heights - white moves float above, black sink below
598
+ BASE_Z_WHITE = 2.2
599
+ BASE_Z_BLACK = -0.3
600
+ Z_LAYER_STEP = 0.25 # Each subsequent move goes higher/lower
601
+
602
+ # Track all nodes for edge drawing
603
+ all_nodes = [] # List of (x, y, z, move_uci, is_white, move_idx)
604
+
605
+ for i, move_uci in enumerate(move_history):
606
+ is_white = (i % 2 == 0)
607
+ move_num = i // 2 + 1
608
+ same_color_idx = i // 2 # How many moves of this color so far
609
+
610
+ # Parse the UCI move to get destination square
611
+ if len(move_uci) >= 4:
612
+ to_sq_name = move_uci[2:4] # e.g., "e4" from "e2e4"
613
+ try:
614
+ to_sq = chess.parse_square(to_sq_name)
615
+ # Get 3D position of destination square
616
+ x, y, _ = square_to_3d(to_sq)
617
+ except:
618
+ x, y = 0, 0
619
+ else:
620
+ x, y = 0, 0
621
+
622
+ # Z position - stacks up/down with each move
623
+ if is_white:
624
+ z = BASE_Z_WHITE + (same_color_idx * Z_LAYER_STEP)
625
+ node_color = '#FFFEF0'
626
+ edge_color = 'rgba(255,254,240,0.6)'
627
+ glow_color = 'rgba(0,255,212,0.4)' # Cyan glow
628
+ else:
629
+ z = BASE_Z_BLACK - (same_color_idx * Z_LAYER_STEP)
630
+ node_color = '#AAAAAA'
631
+ edge_color = 'rgba(150,150,150,0.6)'
632
+ glow_color = 'rgba(255,100,150,0.4)' # Pink glow
633
+
634
+ all_nodes.append((x, y, z, move_uci, is_white, i))
635
+
636
+ # Draw vertical "drop line" from node to board
637
+ board_z = 0.1
638
+ traces.append(go.Scatter3d(
639
+ x=[x, x], y=[y, y], z=[z, board_z if is_white else board_z],
640
+ mode='lines',
641
+ line=dict(color=glow_color, width=1),
642
+ hoverinfo='skip', showlegend=False
643
+ ))
644
+
645
+ # Node marker
646
+ traces.append(go.Scatter3d(
647
+ x=[x], y=[y], z=[z],
648
+ mode='markers+text',
649
+ marker=dict(size=10, color=node_color,
650
+ line=dict(width=2, color=glow_color),
651
+ symbol='diamond'),
652
+ text=[f"{move_num}.{move_uci}" if is_white else move_uci],
653
+ textposition='top center',
654
+ textfont=dict(size=9, color=node_color, family='monospace'),
655
+ hoverinfo='text',
656
+ hovertext=f"{'White' if is_white else 'Black'} {move_num}: {move_uci}",
657
+ showlegend=False
658
+ ))
659
+
660
+ # Draw edges connecting sequential moves (alternating colors)
661
+ for i in range(len(all_nodes) - 1):
662
+ x0, y0, z0, _, _, _ = all_nodes[i]
663
+ x1, y1, z1, _, is_white_next, _ = all_nodes[i + 1]
664
+
665
+ # Color based on who just moved (the edge leads TO the next move)
666
+ edge_color = 'rgba(0,255,212,0.4)' if is_white_next else 'rgba(255,100,150,0.4)'
667
+
668
+ traces.append(go.Scatter3d(
669
+ x=[x0, x1], y=[y0, y1], z=[z0, z1],
670
+ mode='lines',
671
+ line=dict(color=edge_color, width=2),
672
+ hoverinfo='skip', showlegend=False
673
+ ))
674
+
675
+ # Show candidate moves as potential branches from last position
676
+ if candidates and all_nodes:
677
+ last_x, last_y, last_z, _, last_white, _ = all_nodes[-1]
678
+ is_white_turn = len(move_history) % 2 == 0 # Who moves next
679
+
680
+ branch_z = (BASE_Z_WHITE + (len(move_history)//2) * Z_LAYER_STEP) if is_white_turn \
681
+ else (BASE_Z_BLACK - (len(move_history)//2) * Z_LAYER_STEP)
682
+
683
+ for j, cand in enumerate(candidates[:5]):
684
+ # Position at candidate's destination
685
+ if len(cand.move) >= 4:
686
+ try:
687
+ cand_to_sq = chess.parse_square(cand.move[2:4])
688
+ cx, cy, _ = square_to_3d(cand_to_sq)
689
+ except:
690
+ continue
691
+ else:
692
+ continue
693
+
694
+ cz = branch_z
695
+
696
+ # Branch edge from last move to candidate
697
+ traces.append(go.Scatter3d(
698
+ x=[last_x, cx], y=[last_y, cy], z=[last_z, cz],
699
+ mode='lines',
700
+ line=dict(color='rgba(255,215,0,0.3)', width=1, dash='dot'),
701
+ hoverinfo='skip', showlegend=False
702
+ ))
703
+
704
+ # Candidate node
705
+ traces.append(go.Scatter3d(
706
+ x=[cx], y=[cy], z=[cz],
707
+ mode='markers+text',
708
+ marker=dict(size=6, color=GOLD, opacity=0.5,
709
+ symbol='diamond'),
710
+ text=[cand.move],
711
+ textposition='top center',
712
+ textfont=dict(size=7, color='rgba(255,215,0,0.6)', family='monospace'),
713
+ hoverinfo='text',
714
+ hovertext=f"Candidate: {cand.move} ({cand.cp}cp)",
715
+ showlegend=False
716
+ ))
717
+
718
+ return traces
719
+
720
+
721
+ # Default camera position
722
+ DEFAULT_CAMERA = dict(
723
+ eye=dict(x=0.8, y=-1.4, z=0.9),
724
+ up=dict(x=0, y=0, z=1),
725
+ center=dict(x=0, y=0, z=0)
726
+ )
727
+
728
+ def create_figure(board: chess.Board, candidates: List[MoveCandidate] = None, camera: dict = None):
729
+ """Create the full 3D figure with polished visuals."""
730
+ fig = go.Figure()
731
+
732
+ # Use provided camera or default
733
+ cam = camera if camera else DEFAULT_CAMERA
734
+
735
+ # Layer 1: Board
736
+ for trace in create_board_traces():
737
+ fig.add_trace(trace)
738
+
739
+ # Layer 2: Pieces (crystal style)
740
+ for trace in create_piece_traces(board):
741
+ fig.add_trace(trace)
742
+
743
+ # Layer 3: Move arcs (when HOLD active)
744
+ if candidates:
745
+ # Highlight candidate origin pieces
746
+ for trace in create_candidate_origins(board, candidates):
747
+ fig.add_trace(trace)
748
+
749
+ is_white = board.turn == chess.WHITE
750
+ for trace in create_move_arcs(candidates, is_white):
751
+ fig.add_trace(trace)
752
+
753
+ # Turn indicator
754
+ turn_text = "⚪ WHITE" if board.turn else "⚫ BLACK"
755
+ turn_color = '#EEE' if board.turn else '#888'
756
+ fig.add_trace(go.Scatter3d(
757
+ x=[0], y=[5.2], z=[0.3], mode='text', text=[turn_text],
758
+ textfont=dict(size=14, color=turn_color, family='monospace'),
759
+ showlegend=False, hoverinfo='skip'
760
+ ))
761
+
762
+ # Move number
763
+ fig.add_trace(go.Scatter3d(
764
+ x=[0], y=[-5.2], z=[0.3], mode='text',
765
+ text=[f"Move {board.fullmove_number}"],
766
+ textfont=dict(size=11, color='#666', family='monospace'),
767
+ showlegend=False, hoverinfo='skip'
768
+ ))
769
+
770
+ fig.update_layout(
771
+ scene=dict(
772
+ xaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False,
773
+ showticklabels=False, showline=False, zeroline=False, title=''),
774
+ yaxis=dict(range=[-5.5, 5.5], showgrid=False, showbackground=False,
775
+ showticklabels=False, showline=False, zeroline=False, title=''),
776
+ zaxis=dict(range=[-0.5, 3.0], showgrid=False, showbackground=False,
777
+ showticklabels=False, showline=False, zeroline=False, title=''),
778
+ aspectmode='manual',
779
+ aspectratio=dict(x=1, y=1, z=0.35),
780
+ camera=cam,
781
+ bgcolor=BG_COLOR
782
+ ),
783
+ paper_bgcolor=BG_COLOR,
784
+ plot_bgcolor=BG_COLOR,
785
+ margin=dict(l=0, r=0, t=0, b=0),
786
+ showlegend=False,
787
+ height=650
788
+ )
789
+ return fig
790
+
791
+ # ═══════════════════════════════════════════════════════════════════════════════
792
+ # ENGINE + CASCADE HELPERS
793
+ # ═══════════════════════════════════════════════════════════════════════════════
794
+
795
+ import time
796
+
797
+ def get_candidates_with_trace(board: chess.Board, num=5) -> tuple:
798
+ """Get move candidates from Stockfish WITH cascade-lattice tracing."""
799
+ global TRACE_LOG, DECISION_TREE
800
+
801
+ trace_data = []
802
+ decision_data = []
803
+ start_time = time.perf_counter()
804
+
805
+ if not ENGINE:
806
+ return [], trace_data, decision_data
807
+
808
+ try:
809
+ # TRACE: Board state encoding
810
+ t0 = time.perf_counter()
811
+ fen = board.fen()
812
+ trace_data.append({
813
+ 'step': 1, 'op': 'ENCODE', 'detail': f'FEN → tensor',
814
+ 'input': fen[:20] + '...', 'output': 'state_vec[768]',
815
+ 'duration': round((time.perf_counter() - t0) * 1000, 2),
816
+ 'confidence': 1.0
817
+ })
818
+
819
+ # TRACE: Engine analysis
820
+ t1 = time.perf_counter()
821
+ info = ENGINE.analyse(board, chess.engine.Limit(depth=10), multipv=num)
822
+ trace_data.append({
823
+ 'step': 2, 'op': 'ANALYZE', 'detail': f'Stockfish depth=10',
824
+ 'input': 'state_vec', 'output': f'{len(info)} candidates',
825
+ 'duration': round((time.perf_counter() - t1) * 1000, 2),
826
+ 'confidence': 0.95
827
+ })
828
+
829
+ candidates = []
830
+ total = 0
831
+
832
+ # TRACE: Candidate scoring
833
+ t2 = time.perf_counter()
834
+ for i, pv in enumerate(info):
835
+ move = pv['pv'][0]
836
+ score = pv.get('score', chess.engine.Cp(0))
837
+
838
+ if score.is_mate():
839
+ value = 1.0 if score.mate() > 0 else -1.0
840
+ eval_str = f"M{score.mate()}"
841
+ else:
842
+ cp = score.relative.score(mate_score=10000)
843
+ value = max(-1, min(1, cp / 1000))
844
+ eval_str = f"{cp:+d}cp"
845
+
846
+ prob = 1.0 / (i + 1)
847
+ total += prob
848
+
849
+ cand = MoveCandidate(
850
+ move=move.uci(), prob=prob, value=value,
851
+ from_sq=move.from_square, to_sq=move.to_square,
852
+ is_capture=board.is_capture(move),
853
+ is_check=board.gives_check(move)
854
+ )
855
+ candidates.append(cand)
856
+
857
+ # Decision tree entry
858
+ decision_data.append({
859
+ 'move': move.uci(),
860
+ 'eval': eval_str,
861
+ 'prob': prob,
862
+ 'rank': i + 1,
863
+ 'capture': board.is_capture(move),
864
+ 'check': board.gives_check(move),
865
+ 'selected': i == 0
866
+ })
867
+
868
+ trace_data.append({
869
+ 'step': 3, 'op': 'SCORE', 'detail': f'Evaluate {len(candidates)} moves',
870
+ 'input': 'raw_candidates', 'output': 'scored_candidates',
871
+ 'duration': round((time.perf_counter() - t2) * 1000, 2),
872
+ 'confidence': 0.88
873
+ })
874
+
875
+ # Normalize probabilities
876
+ for c in candidates:
877
+ c.prob /= total
878
+ for d in decision_data:
879
+ d['prob'] /= total
880
+
881
+ # TRACE: Hold decision
882
+ t3 = time.perf_counter()
883
+ if HOLD and candidates:
884
+ # Use cascade-lattice Hold to potentially override
885
+ hold_result = None
886
+ try:
887
+ # Hold.evaluate expects candidates, returns selected
888
+ hold_result = HOLD.evaluate([c.move for c in candidates],
889
+ weights=[c.prob for c in candidates])
890
+ except:
891
+ pass
892
+
893
+ trace_data.append({
894
+ 'step': 4, 'op': 'HOLD', 'detail': f'cascade.Hold decision gate',
895
+ 'input': 'scored_candidates', 'output': candidates[0].move if candidates else 'none',
896
+ 'duration': round((time.perf_counter() - t3) * 1000, 2),
897
+ 'confidence': candidates[0].prob if candidates else 0
898
+ })
899
+
900
+ # TRACE: Final selection
901
+ total_time = (time.perf_counter() - start_time) * 1000
902
+ trace_data.append({
903
+ 'step': 5, 'op': 'SELECT', 'detail': f'Final output',
904
+ 'input': candidates[0].move if candidates else '-',
905
+ 'output': '✓ COMMITTED',
906
+ 'duration': round(total_time, 2),
907
+ 'confidence': 1.0
908
+ })
909
+
910
+ TRACE_LOG = trace_data
911
+ DECISION_TREE = decision_data
912
+
913
+ return candidates, trace_data, decision_data
914
+
915
+ except Exception as e:
916
+ print(f"[ENGINE] Error: {e}")
917
+ return [], [], []
918
+
919
+ def get_candidates(board: chess.Board, num=5) -> List[MoveCandidate]:
920
+ """Simple wrapper for backward compat."""
921
+ candidates, _, _ = get_candidates_with_trace(board, num)
922
+ return candidates
923
+
924
+ # ═══════════════════════════════════════════════════════════════════════════════
925
+ # DASH APP - TWO PANEL LAYOUT
926
+ # ═══════════════════════════════════════════════════════════════════════════════
927
+
928
+ app = dash.Dash(__name__, suppress_callback_exceptions=True)
929
+
930
+ # Styles
931
+ PANEL_STYLE = {
932
+ 'backgroundColor': '#0d0d12',
933
+ 'borderRadius': '8px',
934
+ 'padding': '15px',
935
+ 'border': '1px solid #1a1a2e'
936
+ }
937
+ TRACE_ROW_STYLE = {
938
+ 'display': 'flex', 'alignItems': 'center', 'padding': '8px 10px',
939
+ 'borderBottom': '1px solid #1a1a2e', 'fontFamily': 'monospace', 'fontSize': '12px'
940
+ }
941
+ BUTTON_BASE = {
942
+ 'margin': '5px', 'padding': '12px 24px', 'fontSize': '14px',
943
+ 'backgroundColor': '#1a1a2e', 'borderRadius': '4px',
944
+ 'cursor': 'pointer', 'fontFamily': 'monospace'
945
+ }
946
+
947
+ app.layout = html.Div([
948
+ # Header
949
+ html.Div([
950
+ html.H1("CASCADE // LATTICE",
951
+ style={'color': CYAN, 'margin': 0, 'fontFamily': 'Consolas, monospace',
952
+ 'fontSize': '2.2em', 'letterSpacing': '0.1em', 'display': 'inline-block'}),
953
+ html.Span(" × ", style={'color': '#333', 'fontSize': '1.5em', 'margin': '0 10px'}),
954
+ html.Span("INFERENCE VISUALIZATION",
955
+ style={'color': '#444', 'fontFamily': 'monospace', 'fontSize': '1.1em'})
956
+ ], style={'textAlign': 'center', 'padding': '20px 0', 'borderBottom': '1px solid #1a1a2e'}),
957
+
958
+ # Controls with loading indicator
959
+ html.Div([
960
+ html.Button("⏭ STEP", id='btn-step', n_clicks=0,
961
+ style={**BUTTON_BASE, 'color': '#888', 'border': '1px solid #333'}),
962
+ html.Button("⏸ HOLD", id='btn-hold', n_clicks=0,
963
+ style={**BUTTON_BASE, 'color': GOLD, 'border': f'1px solid {GOLD}'}),
964
+ html.Button("▶▶ AUTO", id='btn-auto', n_clicks=0,
965
+ style={**BUTTON_BASE, 'color': CYAN, 'border': f'1px solid {CYAN}'}),
966
+ html.Button("↺ RESET", id='btn-reset', n_clicks=0,
967
+ style={**BUTTON_BASE, 'color': CRIMSON, 'border': f'1px solid {CRIMSON}'}),
968
+ # Loading spinner
969
+ dcc.Loading(
970
+ id='loading-indicator',
971
+ type='circle',
972
+ color=GOLD,
973
+ children=html.Div(id='loading-output', style={'display': 'inline-block', 'marginLeft': '15px'})
974
+ ),
975
+ ], style={'textAlign': 'center', 'padding': '15px', 'display': 'flex',
976
+ 'justifyContent': 'center', 'alignItems': 'center', 'gap': '5px'}),
977
+
978
+ # Status bar
979
+ html.Div(id='status',
980
+ style={'textAlign': 'center', 'color': '#666', 'padding': '10px',
981
+ 'fontFamily': 'monospace', 'fontSize': '13px', 'backgroundColor': '#0a0a0f',
982
+ 'borderTop': '1px solid #1a1a2e', 'borderBottom': '1px solid #1a1a2e'}),
983
+
984
+ # ═══════════════════════════════════════════════════════════════════════════
985
+ # MAIN THREE-COLUMN LAYOUT
986
+ # ═══════════════════════════════════════════════════════════════════════════
987
+ html.Div([
988
+ # LEFT COLUMN - Engine & Game Info
989
+ html.Div([
990
+ # Engine Info Panel
991
+ html.Div([
992
+ html.Div([
993
+ html.Span("◈ ", style={'color': '#FF6B35'}),
994
+ html.Span("ENGINE", style={'color': '#888'})
995
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px',
996
+ 'borderBottom': '1px solid #FF6B3533', 'paddingBottom': '8px'}),
997
+
998
+ html.Div([
999
+ html.Div([
1000
+ html.Span("Model", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1001
+ html.Span("Stockfish 17", style={'color': '#FF6B35', 'fontWeight': 'bold'})
1002
+ ], style={'marginBottom': '8px'}),
1003
+ html.Div([
1004
+ html.Span("Type", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1005
+ html.Span("NNUE + Classical", style={'color': '#888'})
1006
+ ], style={'marginBottom': '8px'}),
1007
+ html.Div([
1008
+ html.Span("Depth", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1009
+ html.Span("10 ply", style={'color': CYAN})
1010
+ ], style={'marginBottom': '8px'}),
1011
+ html.Div([
1012
+ html.Span("MultiPV", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1013
+ html.Span("5 lines", style={'color': GOLD})
1014
+ ], style={'marginBottom': '8px'}),
1015
+ html.Div([
1016
+ html.Span("Status", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1017
+ html.Span("● READY" if ENGINE else "○ OFFLINE",
1018
+ style={'color': '#0F0' if ENGINE else CRIMSON})
1019
+ ]),
1020
+ ], style={'fontFamily': 'monospace', 'fontSize': '12px'})
1021
+ ], style={**PANEL_STYLE, 'marginBottom': '15px'}),
1022
+
1023
+ # Cascade-Lattice Info Panel
1024
+ html.Div([
1025
+ html.Div([
1026
+ html.Span("◈ ", style={'color': CYAN}),
1027
+ html.Span("CASCADE-LATTICE", style={'color': '#888'})
1028
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px',
1029
+ 'borderBottom': f'1px solid {CYAN}33', 'paddingBottom': '8px'}),
1030
+
1031
+ html.Div([
1032
+ html.Div([
1033
+ html.Span("Package", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1034
+ html.Span("cascade-lattice", style={'color': CYAN})
1035
+ ], style={'marginBottom': '8px'}),
1036
+ html.Div([
1037
+ html.Span("Version", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1038
+ html.Span("0.5.6", style={'color': '#888'})
1039
+ ], style={'marginBottom': '8px'}),
1040
+ html.Div([
1041
+ html.Span("Hold", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1042
+ html.Span("● ACTIVE" if HOLD else "○ OFF",
1043
+ style={'color': '#0F0' if HOLD else '#555'})
1044
+ ], style={'marginBottom': '8px'}),
1045
+ html.Div([
1046
+ html.Span("Causation", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1047
+ html.Span("● TRACING" if CAUSATION else "○ OFF",
1048
+ style={'color': MAGENTA if CAUSATION else '#555'})
1049
+ ]),
1050
+ ], style={'fontFamily': 'monospace', 'fontSize': '12px'})
1051
+ ], style={**PANEL_STYLE, 'marginBottom': '15px'}),
1052
+
1053
+ # Game State Panel
1054
+ html.Div([
1055
+ html.Div([
1056
+ html.Span("◈ ", style={'color': GOLD}),
1057
+ html.Span("GAME STATE", style={'color': '#888'})
1058
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px',
1059
+ 'borderBottom': f'1px solid {GOLD}33', 'paddingBottom': '8px'}),
1060
+
1061
+ html.Div(id='game-state-panel', children=[
1062
+ html.Div([
1063
+ html.Span("Turn", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1064
+ html.Span("White", id='gs-turn', style={'color': '#FFF'})
1065
+ ], style={'marginBottom': '8px'}),
1066
+ html.Div([
1067
+ html.Span("Move #", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1068
+ html.Span("1", id='gs-movenum', style={'color': GOLD})
1069
+ ], style={'marginBottom': '8px'}),
1070
+ html.Div([
1071
+ html.Span("Material", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1072
+ html.Span("0", id='gs-material', style={'color': '#888'})
1073
+ ], style={'marginBottom': '8px'}),
1074
+ html.Div([
1075
+ html.Span("Phase", style={'color': '#555', 'width': '70px', 'display': 'inline-block'}),
1076
+ html.Span("Opening", id='gs-phase', style={'color': '#888'})
1077
+ ]),
1078
+ ], style={'fontFamily': 'monospace', 'fontSize': '12px'})
1079
+ ], style=PANEL_STYLE),
1080
+
1081
+ ], style={'flex': '0 0 220px', 'padding': '0 15px 0 0'}),
1082
+
1083
+ # MIDDLE COLUMN - 3D Chess Board
1084
+ html.Div([
1085
+ dcc.Graph(id='chess-3d', figure=create_figure(chess.Board()),
1086
+ config={'displayModeBar': True, 'scrollZoom': True,
1087
+ 'modeBarButtonsToRemove': ['toImage', 'sendDataToCloud']},
1088
+ style={'height': '580px'}),
1089
+ # Move buttons (when HOLD)
1090
+ html.Div(id='move-buttons', style={'textAlign': 'center', 'padding': '10px'})
1091
+ ], style={'flex': '1', 'minWidth': '450px'}),
1092
+
1093
+ # RIGHT COLUMN - Cascade Panel
1094
+ html.Div([
1095
+ # Cascade Trace Panel
1096
+ html.Div([
1097
+ html.Div([
1098
+ html.Span("◈ ", style={'color': CYAN}),
1099
+ html.Span("CAUSATION TRACE", style={'color': '#888'})
1100
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px',
1101
+ 'borderBottom': f'1px solid {CYAN}33', 'paddingBottom': '8px'}),
1102
+
1103
+ html.Div(id='cascade-trace', children=[
1104
+ html.Div("Waiting for move...", style={'color': '#444', 'fontStyle': 'italic',
1105
+ 'padding': '20px', 'textAlign': 'center'})
1106
+ ])
1107
+ ], style={**PANEL_STYLE, 'marginBottom': '15px'}),
1108
+
1109
+ # Decision Tree Panel
1110
+ html.Div([
1111
+ html.Div([
1112
+ html.Span("◈ ", style={'color': GOLD}),
1113
+ html.Span("DECISION TREE", style={'color': '#888'})
1114
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px',
1115
+ 'borderBottom': f'1px solid {GOLD}33', 'paddingBottom': '8px'}),
1116
+
1117
+ html.Div(id='decision-tree', children=[
1118
+ html.Div("No candidates yet", style={'color': '#444', 'fontStyle': 'italic',
1119
+ 'padding': '20px', 'textAlign': 'center'})
1120
+ ])
1121
+ ], style={**PANEL_STYLE, 'marginBottom': '15px'}),
1122
+
1123
+ # Metrics Panel
1124
+ html.Div([
1125
+ html.Div([
1126
+ html.Span("◈ ", style={'color': MAGENTA}),
1127
+ html.Span("METRICS", style={'color': '#888'})
1128
+ ], style={'fontFamily': 'monospace', 'fontSize': '14px', 'marginBottom': '12px',
1129
+ 'borderBottom': f'1px solid {MAGENTA}33', 'paddingBottom': '8px'}),
1130
+
1131
+ html.Div(id='metrics-panel', children=[
1132
+ html.Div([
1133
+ html.Span("Total Latency: ", style={'color': '#555'}),
1134
+ html.Span("--ms", id='metric-latency', style={'color': CYAN})
1135
+ ], style={'marginBottom': '5px'}),
1136
+ html.Div([
1137
+ html.Span("Candidates: ", style={'color': '#555'}),
1138
+ html.Span("0", id='metric-candidates', style={'color': GOLD})
1139
+ ], style={'marginBottom': '5px'}),
1140
+ html.Div([
1141
+ html.Span("Confidence: ", style={'color': '#555'}),
1142
+ html.Span("--%", id='metric-confidence', style={'color': MAGENTA})
1143
+ ]),
1144
+ ], style={'fontFamily': 'monospace', 'fontSize': '13px'})
1145
+ ], style=PANEL_STYLE),
1146
+
1147
+ ], style={'flex': '1', 'minWidth': '350px', 'maxWidth': '450px', 'padding': '0 15px'}),
1148
+
1149
+ ], style={'display': 'flex', 'padding': '20px', 'gap': '10px', 'alignItems': 'flex-start'}),
1150
+
1151
+ # Hidden stores
1152
+ dcc.Store(id='board-fen', data=chess.STARTING_FEN),
1153
+ dcc.Store(id='candidates-store', data=[]),
1154
+ dcc.Store(id='trace-store', data=[]),
1155
+ dcc.Store(id='decision-store', data=[]),
1156
+ dcc.Store(id='is-held', data=False),
1157
+ dcc.Store(id='auto-play', data=False),
1158
+ dcc.Store(id='move-history', data=[]),
1159
+ dcc.Store(id='camera-store', data=None), # Stores user's camera position
1160
+
1161
+ # Auto-play interval
1162
+ dcc.Interval(id='auto-interval', interval=1200, disabled=True),
1163
+
1164
+ ], style={'backgroundColor': BG_COLOR, 'minHeight': '100vh', 'padding': '0'})
1165
+
1166
+ @callback(
1167
+ Output('board-fen', 'data'),
1168
+ Output('candidates-store', 'data'),
1169
+ Output('trace-store', 'data'),
1170
+ Output('decision-store', 'data'),
1171
+ Output('is-held', 'data'),
1172
+ Output('move-history', 'data'),
1173
+ Output('auto-play', 'data'),
1174
+ Input('btn-step', 'n_clicks'),
1175
+ Input('btn-hold', 'n_clicks'),
1176
+ Input('btn-reset', 'n_clicks'),
1177
+ Input('btn-auto', 'n_clicks'),
1178
+ Input('auto-interval', 'n_intervals'),
1179
+ State('board-fen', 'data'),
1180
+ State('candidates-store', 'data'),
1181
+ State('is-held', 'data'),
1182
+ State('move-history', 'data'),
1183
+ State('auto-play', 'data'),
1184
+ prevent_initial_call=True
1185
+ )
1186
+ def handle_controls(step, hold, reset, auto, interval, fen, candidates, is_held, history, auto_play):
1187
+ ctx = dash.callback_context
1188
+ if not ctx.triggered:
1189
+ return fen, candidates, [], [], is_held, history, auto_play
1190
+
1191
+ trigger = ctx.triggered[0]['prop_id'].split('.')[0]
1192
+ board = chess.Board(fen)
1193
+
1194
+ if trigger == 'btn-reset':
1195
+ return chess.STARTING_FEN, [], [], [], False, [], False
1196
+
1197
+ if trigger == 'btn-auto':
1198
+ return fen, [], [], [], False, history, not auto_play # Toggle auto
1199
+
1200
+ if trigger == 'btn-hold':
1201
+ if not is_held:
1202
+ cands, trace, decision = get_candidates_with_trace(board)
1203
+ return fen, [c.__dict__ for c in cands], trace, decision, True, history, False
1204
+ else:
1205
+ return fen, [], [], [], False, history, auto_play
1206
+
1207
+ if trigger in ['btn-step', 'auto-interval']:
1208
+ if board.is_game_over():
1209
+ return fen, [], [], [], False, history, False
1210
+
1211
+ cands, trace, decision = get_candidates_with_trace(board)
1212
+ if cands:
1213
+ move = chess.Move.from_uci(cands[0].move)
1214
+ board.push(move)
1215
+ history = history + [cands[0].move]
1216
+ return board.fen(), [], trace, decision, False, history, auto_play
1217
+
1218
+ return fen, candidates, [], [], is_held, history, auto_play
1219
+
1220
+ @callback(
1221
+ Output('chess-3d', 'figure'),
1222
+ Output('loading-output', 'children'),
1223
+ Input('board-fen', 'data'),
1224
+ Input('candidates-store', 'data'),
1225
+ State('chess-3d', 'figure') # Read current figure to get its camera
1226
+ )
1227
+ def update_figure(fen, candidates_data, current_fig):
1228
+ board = chess.Board(fen)
1229
+ candidates = [MoveCandidate(**c) for c in candidates_data] if candidates_data else None
1230
+
1231
+ # Extract camera from current figure if it exists
1232
+ camera = None
1233
+ if current_fig and 'layout' in current_fig:
1234
+ scene = current_fig['layout'].get('scene', {})
1235
+ if 'camera' in scene:
1236
+ camera = scene['camera']
1237
+
1238
+ return create_figure(board, candidates, camera), ""
1239
+
1240
+ @callback(
1241
+ Output('btn-hold', 'children'),
1242
+ Output('btn-hold', 'style'),
1243
+ Input('is-held', 'data')
1244
+ )
1245
+ def update_hold_button(is_held):
1246
+ if is_held:
1247
+ return "◉ HOLDING", {**BUTTON_BASE, 'color': '#000', 'backgroundColor': GOLD,
1248
+ 'border': f'2px solid {GOLD}', 'fontWeight': 'bold'}
1249
+ else:
1250
+ return "⏸ HOLD", {**BUTTON_BASE, 'color': GOLD, 'border': f'1px solid {GOLD}'}
1251
+
1252
+ @callback(
1253
+ Output('status', 'children'),
1254
+ Input('board-fen', 'data'),
1255
+ Input('is-held', 'data'),
1256
+ Input('auto-play', 'data'),
1257
+ Input('move-history', 'data')
1258
+ )
1259
+ def update_status(fen, is_held, auto_play, history):
1260
+ board = chess.Board(fen)
1261
+
1262
+ if board.is_game_over():
1263
+ result = board.result()
1264
+ return f"GAME OVER: {result}"
1265
+
1266
+ turn = "WHITE" if board.turn else "BLACK"
1267
+ mode = "◉ HOLD ACTIVE - Select a move" if is_held else ("▶▶ AUTO" if auto_play else "MANUAL")
1268
+ return f"Move {board.fullmove_number} | {turn} | {mode}"
1269
+
1270
+ @callback(
1271
+ Output('auto-interval', 'disabled'),
1272
+ Input('auto-play', 'data')
1273
+ )
1274
+ def toggle_auto(auto_play):
1275
+ return not auto_play
1276
+
1277
+ @callback(
1278
+ Output('gs-turn', 'children'),
1279
+ Output('gs-turn', 'style'),
1280
+ Output('gs-movenum', 'children'),
1281
+ Output('gs-material', 'children'),
1282
+ Output('gs-material', 'style'),
1283
+ Output('gs-phase', 'children'),
1284
+ Input('board-fen', 'data')
1285
+ )
1286
+ def update_game_state(fen):
1287
+ board = chess.Board(fen)
1288
+
1289
+ # Turn
1290
+ turn_text = "White" if board.turn else "Black"
1291
+ turn_style = {'color': '#FFF'} if board.turn else {'color': '#888'}
1292
+
1293
+ # Move number
1294
+ move_num = str(board.fullmove_number)
1295
+
1296
+ # Material count (simple piece values)
1297
+ piece_values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
1298
+ chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
1299
+ white_material = sum(piece_values.get(p.piece_type, 0)
1300
+ for p in board.piece_map().values() if p.color == chess.WHITE)
1301
+ black_material = sum(piece_values.get(p.piece_type, 0)
1302
+ for p in board.piece_map().values() if p.color == chess.BLACK)
1303
+ diff = white_material - black_material
1304
+ if diff > 0:
1305
+ mat_text = f"+{diff} ⚪"
1306
+ mat_style = {'color': '#0F0'}
1307
+ elif diff < 0:
1308
+ mat_text = f"{diff} ⚫"
1309
+ mat_style = {'color': CRIMSON}
1310
+ else:
1311
+ mat_text = "Equal"
1312
+ mat_style = {'color': '#888'}
1313
+
1314
+ # Game phase (rough estimate)
1315
+ total_pieces = len(board.piece_map())
1316
+ if total_pieces >= 28:
1317
+ phase = "Opening"
1318
+ elif total_pieces >= 14:
1319
+ phase = "Middlegame"
1320
+ else:
1321
+ phase = "Endgame"
1322
+
1323
+ if board.is_check():
1324
+ phase = "⚠ CHECK"
1325
+ if board.is_game_over():
1326
+ phase = "Game Over"
1327
+
1328
+ return turn_text, turn_style, move_num, mat_text, mat_style, phase
1329
+
1330
+ # ═══════════════════════════════════════════════════════════════════════════════
1331
+ # CASCADE PANEL CALLBACKS
1332
+ # ═══════════════════════════════════════════════════════════════════════════════
1333
+
1334
+ @callback(
1335
+ Output('cascade-trace', 'children'),
1336
+ Input('trace-store', 'data')
1337
+ )
1338
+ def render_trace(trace_data):
1339
+ if not trace_data:
1340
+ return html.Div("Waiting for move...",
1341
+ style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'})
1342
+
1343
+ rows = []
1344
+ for t in trace_data:
1345
+ # Color code by operation type
1346
+ op_colors = {'ENCODE': CYAN, 'ANALYZE': '#888', 'SCORE': GOLD, 'HOLD': MAGENTA, 'SELECT': '#0F0'}
1347
+ op_color = op_colors.get(t['op'], '#666')
1348
+
1349
+ # Confidence bar
1350
+ conf_pct = t['confidence'] * 100
1351
+
1352
+ row = html.Div([
1353
+ # Step number
1354
+ html.Span(f"{t['step']}", style={'color': '#444', 'width': '20px', 'marginRight': '10px'}),
1355
+ # Operation badge
1356
+ html.Span(t['op'], style={
1357
+ 'backgroundColor': f'{op_color}22', 'color': op_color,
1358
+ 'padding': '2px 8px', 'borderRadius': '3px', 'fontSize': '10px',
1359
+ 'fontWeight': 'bold', 'width': '60px', 'textAlign': 'center', 'marginRight': '10px'
1360
+ }),
1361
+ # Detail
1362
+ html.Span(t['detail'], style={'color': '#888', 'flex': '1', 'fontSize': '11px'}),
1363
+ # Duration
1364
+ html.Span(f"{t['duration']}ms", style={'color': '#555', 'width': '60px', 'textAlign': 'right'}),
1365
+ ], style={**TRACE_ROW_STYLE})
1366
+ rows.append(row)
1367
+
1368
+ # Total latency
1369
+ total = sum(t['duration'] for t in trace_data)
1370
+ rows.append(html.Div([
1371
+ html.Span("", style={'width': '90px'}),
1372
+ html.Span("TOTAL", style={'color': CYAN, 'fontWeight': 'bold', 'flex': '1'}),
1373
+ html.Span(f"{total:.1f}ms", style={'color': CYAN, 'fontWeight': 'bold', 'width': '60px', 'textAlign': 'right'})
1374
+ ], style={**TRACE_ROW_STYLE, 'borderBottom': 'none', 'backgroundColor': '#0a0a0f'}))
1375
+
1376
+ return rows
1377
+
1378
+ @callback(
1379
+ Output('decision-tree', 'children'),
1380
+ Input('decision-store', 'data')
1381
+ )
1382
+ def render_decision_tree(decision_data):
1383
+ if not decision_data:
1384
+ return html.Div("No candidates yet",
1385
+ style={'color': '#444', 'fontStyle': 'italic', 'padding': '20px', 'textAlign': 'center'})
1386
+
1387
+ rows = []
1388
+ for d in decision_data:
1389
+ is_selected = d.get('selected', False)
1390
+ bg_color = f'{CYAN}15' if is_selected else 'transparent'
1391
+ border_left = f'3px solid {CYAN}' if is_selected else '3px solid transparent'
1392
+
1393
+ # Probability bar
1394
+ prob_pct = d['prob'] * 100
1395
+
1396
+ row = html.Div([
1397
+ # Rank
1398
+ html.Span(f"#{d['rank']}", style={
1399
+ 'color': CYAN if is_selected else '#555',
1400
+ 'width': '30px', 'fontWeight': 'bold' if is_selected else 'normal'
1401
+ }),
1402
+ # Move
1403
+ html.Span(d['move'], style={
1404
+ 'color': '#FFF' if is_selected else '#888',
1405
+ 'fontWeight': 'bold', 'width': '55px', 'fontFamily': 'monospace'
1406
+ }),
1407
+ # Eval
1408
+ html.Span(d['eval'], style={
1409
+ 'color': GOLD if 'M' in str(d['eval']) else ('#0F0' if d['eval'][0] == '+' else CRIMSON),
1410
+ 'width': '55px', 'textAlign': 'right'
1411
+ }),
1412
+ # Probability bar
1413
+ html.Div([
1414
+ html.Div(style={
1415
+ 'width': f'{prob_pct}%', 'height': '8px',
1416
+ 'backgroundColor': CYAN if is_selected else '#333',
1417
+ 'borderRadius': '2px'
1418
+ })
1419
+ ], style={'flex': '1', 'backgroundColor': '#1a1a2e', 'borderRadius': '2px', 'marginLeft': '10px'}),
1420
+ # Percentage
1421
+ html.Span(f"{prob_pct:.0f}%", style={'color': '#666', 'width': '40px', 'textAlign': 'right', 'marginLeft': '8px'}),
1422
+ # Flags
1423
+ html.Span(
1424
+ ("⚔" if d.get('capture') else "") + ("♚" if d.get('check') else ""),
1425
+ style={'color': CRIMSON, 'width': '25px', 'textAlign': 'right'}
1426
+ )
1427
+ ], style={
1428
+ 'display': 'flex', 'alignItems': 'center', 'padding': '8px 10px',
1429
+ 'backgroundColor': bg_color, 'borderLeft': border_left,
1430
+ 'marginBottom': '4px', 'borderRadius': '3px',
1431
+ 'fontFamily': 'monospace', 'fontSize': '12px'
1432
+ })
1433
+ rows.append(row)
1434
+
1435
+ return rows
1436
+
1437
+ @callback(
1438
+ Output('metric-latency', 'children'),
1439
+ Output('metric-candidates', 'children'),
1440
+ Output('metric-confidence', 'children'),
1441
+ Input('trace-store', 'data'),
1442
+ Input('decision-store', 'data')
1443
+ )
1444
+ def update_metrics(trace_data, decision_data):
1445
+ if not trace_data:
1446
+ return "--ms", "0", "--%"
1447
+
1448
+ total_latency = sum(t['duration'] for t in trace_data)
1449
+ num_candidates = len(decision_data) if decision_data else 0
1450
+ confidence = decision_data[0]['prob'] * 100 if decision_data else 0
1451
+
1452
+ return f"{total_latency:.1f}ms", str(num_candidates), f"{confidence:.0f}%"
1453
+
1454
+ @callback(
1455
+ Output('move-buttons', 'children'),
1456
+ Input('candidates-store', 'data'),
1457
+ Input('is-held', 'data')
1458
+ )
1459
+ def show_move_buttons(candidates_data, is_held):
1460
+ if not is_held or not candidates_data:
1461
+ return []
1462
+
1463
+ # Get whose turn it is from the current board state (we'll need this for styling)
1464
+ buttons = []
1465
+ for i, c in enumerate(candidates_data):
1466
+ prob_pct = c['prob'] * 100
1467
+ is_top = i == 0
1468
+
1469
+ btn_style = {
1470
+ 'margin': '5px', 'padding': '10px 20px', 'fontSize': '13px',
1471
+ 'fontFamily': 'monospace', 'borderRadius': '4px', 'cursor': 'pointer',
1472
+ 'backgroundColor': '#1a1a2e' if not is_top else f'{CYAN}22',
1473
+ 'color': CYAN if is_top else '#888',
1474
+ 'border': f'1px solid {CYAN}' if is_top else '1px solid #333'
1475
+ }
1476
+
1477
+ btn = html.Button(
1478
+ f"{c['move']} ({prob_pct:.0f}%)",
1479
+ id={'type': 'move-btn', 'index': i},
1480
+ style=btn_style
1481
+ )
1482
+ buttons.append(btn)
1483
+ return buttons
1484
+
1485
+ # Move selection callback
1486
+ @callback(
1487
+ Output('board-fen', 'data', allow_duplicate=True),
1488
+ Output('candidates-store', 'data', allow_duplicate=True),
1489
+ Output('trace-store', 'data', allow_duplicate=True),
1490
+ Output('decision-store', 'data', allow_duplicate=True),
1491
+ Output('is-held', 'data', allow_duplicate=True),
1492
+ Output('move-history', 'data', allow_duplicate=True),
1493
+ Input({'type': 'move-btn', 'index': dash.ALL}, 'n_clicks'),
1494
+ State('board-fen', 'data'),
1495
+ State('candidates-store', 'data'),
1496
+ State('trace-store', 'data'),
1497
+ State('decision-store', 'data'),
1498
+ State('move-history', 'data'),
1499
+ prevent_initial_call=True
1500
+ )
1501
+ def select_move(clicks, fen, candidates_data, trace_data, decision_data, history):
1502
+ ctx = dash.callback_context
1503
+
1504
+ # Only proceed if an actual button was clicked
1505
+ if not ctx.triggered:
1506
+ raise dash.exceptions.PreventUpdate
1507
+
1508
+ # Check if any click actually happened (not just initialization)
1509
+ if not clicks or not any(c for c in clicks if c):
1510
+ raise dash.exceptions.PreventUpdate
1511
+
1512
+ # Find which button was clicked
1513
+ triggered_id = ctx.triggered[0]['prop_id']
1514
+ if triggered_id == '.':
1515
+ raise dash.exceptions.PreventUpdate
1516
+
1517
+ import json
1518
+ try:
1519
+ idx = json.loads(triggered_id.split('.')[0])['index']
1520
+ except:
1521
+ raise dash.exceptions.PreventUpdate
1522
+
1523
+ board = chess.Board(fen)
1524
+ if candidates_data and idx < len(candidates_data):
1525
+ move_uci = candidates_data[idx]['move']
1526
+ move = chess.Move.from_uci(move_uci)
1527
+ board.push(move)
1528
+ history = history + [move_uci]
1529
+
1530
+ # Return with trace/decision preserved for display
1531
+ return board.fen(), [], trace_data, decision_data, False, history
1532
+
1533
+ if __name__ == '__main__':
1534
+ print("\n" + "="*50)
1535
+ print("CASCADE-LATTICE Chess")
1536
+ print("="*50)
1537
+ print("Open: http://127.0.0.1:8050")
1538
+ print("="*50 + "\n")
1539
+ # Note: debug=False to avoid Python 3.13 socket issues on Windows
1540
+ app.run(debug=False, port=8050)
src/app_threejs.py CHANGED
The diff for this file is too large to render. See raw diff
 
src/assets/chess3d.html CHANGED
@@ -17,10 +17,45 @@
17
  font-size: 11px;
18
  pointer-events: none;
19
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  </style>
21
  </head>
22
  <body>
23
- <div id="info">Drag to rotate • Scroll to zoom</div>
 
 
 
 
 
 
 
24
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
25
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
26
  <script>
@@ -29,17 +64,39 @@
29
  // ═══════════════════════════════════════════════════════════════
30
 
31
  let scene, camera, renderer, controls;
32
- let boardGroup, piecesGroup, arcsGroup;
 
 
 
33
 
34
- // Colors
35
- const BOARD_LIGHT = 0xD4A574;
36
- const BOARD_DARK = 0x8B5A2B;
37
- const BOARD_EDGE = 0x4A3520;
38
- const WHITE_PIECE = 0xFFFEF0;
39
- const BLACK_PIECE = 0x2A2A2A;
40
- const GOLD = 0xFFD700;
41
- const CYAN = 0x00FFD4;
42
- const MAGENTA = 0xFF00AA;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  const BG_COLOR = 0x0a0a0f;
44
 
45
  // Piece heights
@@ -79,7 +136,7 @@
79
  dirLight.position.set(5, 5, 10);
80
  scene.add(dirLight);
81
 
82
- const rimLight = new THREE.DirectionalLight(0x4488ff, 0.3);
83
  rimLight.position.set(-5, -5, 5);
84
  scene.add(rimLight);
85
 
@@ -87,9 +144,15 @@
87
  boardGroup = new THREE.Group();
88
  piecesGroup = new THREE.Group();
89
  arcsGroup = new THREE.Group();
 
90
  scene.add(boardGroup);
91
  scene.add(piecesGroup);
92
  scene.add(arcsGroup);
 
 
 
 
 
93
 
94
  // Create board
95
  createBoard();
@@ -103,6 +166,9 @@
103
  // Listen for messages from Dash
104
  window.addEventListener('message', handleMessage);
105
 
 
 
 
106
  animate();
107
  }
108
 
@@ -239,12 +305,12 @@
239
  group.add(ball);
240
  }
241
 
242
- // Add glow ring at base for style
243
  const glowGeo = new THREE.RingGeometry(0.35, 0.45, 32);
244
  const glowMat = new THREE.MeshBasicMaterial({
245
- color: isWhite ? 0x4488ff : 0xff4488,
246
  transparent: true,
247
- opacity: 0.3,
248
  side: THREE.DoubleSide
249
  });
250
  const glow = new THREE.Mesh(glowGeo, glowMat);
@@ -253,6 +319,17 @@
253
  group.add(glow);
254
 
255
  group.position.set(x - 3.5, y - 3.5, 0);
 
 
 
 
 
 
 
 
 
 
 
256
  return group;
257
  }
258
 
@@ -280,14 +357,14 @@
280
  const thickness = isSelected ? (0.08 + prob * 0.06) : (0.03 + prob * 0.05);
281
  const tubeGeo = new THREE.TubeGeometry(curve, 20, thickness, 8, false);
282
 
283
- // Color selection: selected gets bright white/cyan glow
284
  let color;
285
  if (isSelected) {
286
- color = 0xFFFFFF; // Bright white for selected
287
  } else if (isHuman) {
288
- color = 0x00FF88; // Bright green for human moves
289
  } else if (isBlack) {
290
- color = isCapture ? 0xff2222 : (prob > 0.3 ? 0xff6600 : 0xff4488);
291
  } else {
292
  color = isCapture ? MAGENTA : (prob > 0.3 ? GOLD : CYAN);
293
  }
@@ -342,12 +419,373 @@
342
 
343
  if (data.type === 'update') {
344
  if (data.fen) {
 
345
  updateFromFEN(data.fen);
346
  }
347
  if (data.candidates !== undefined) {
348
  updateCandidates(data.candidates);
349
  }
350
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  }
352
 
353
  function onResize() {
@@ -358,6 +796,10 @@
358
 
359
  function animate() {
360
  requestAnimationFrame(animate);
 
 
 
 
361
  controls.update();
362
  renderer.render(scene, camera);
363
  }
 
17
  font-size: 11px;
18
  pointer-events: none;
19
  }
20
+ #piece-tooltip {
21
+ position: absolute;
22
+ background: rgba(15, 15, 20, 0.92);
23
+ border: 1px solid #506070;
24
+ border-radius: 6px;
25
+ padding: 8px 12px;
26
+ color: #ccc;
27
+ font-size: 11px;
28
+ pointer-events: none;
29
+ display: none;
30
+ max-width: 200px;
31
+ z-index: 1000;
32
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
33
+ }
34
+ #piece-tooltip .piece-name {
35
+ color: #A0A0A0;
36
+ font-weight: bold;
37
+ font-size: 13px;
38
+ margin-bottom: 5px;
39
+ }
40
+ #piece-tooltip .move-count {
41
+ color: #B09040;
42
+ }
43
+ #piece-tooltip .special {
44
+ color: #906070;
45
+ font-style: italic;
46
+ margin-top: 4px;
47
+ }
48
  </style>
49
  </head>
50
  <body>
51
+ <div id="info">Drag to rotate • Scroll to zoom • Hover pieces for legal moves</div>
52
+ <div id="piece-tooltip"></div>
53
+ <div id="replay-overlay" style="display:none; position:absolute; top:15px; right:15px; background:rgba(10,10,20,0.9); border:2px solid #505060; border-radius:8px; padding:12px 18px; color:#ccc; font-family:monospace; z-index:1000; min-width:160px;">
54
+ <div style="color:#D08030; font-weight:bold; font-size:13px; margin-bottom:8px; border-bottom:1px solid #404050; padding-bottom:6px;">🎬 REPLAY</div>
55
+ <div id="replay-move" style="font-size:22px; color:#80B0C0; font-weight:bold; margin-bottom:6px;">e2e4</div>
56
+ <div id="replay-counter" style="font-size:12px; color:#888;">Move 1 / 8</div>
57
+ <div id="replay-progress" style="margin-top:8px; height:4px; background:#303040; border-radius:2px; overflow:hidden;"><div id="replay-bar" style="height:100%; background:#D08030; width:0%; transition:width 0.3s;"></div></div>
58
+ </div>
59
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
60
  <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
61
  <script>
 
64
  // ═══════════════════════════════════════════════════════════════
65
 
66
  let scene, camera, renderer, controls;
67
+ let boardGroup, piecesGroup, arcsGroup, highlightGroup;
68
+ let raycaster, mouse;
69
+ let hoveredPiece = null;
70
+ let currentFEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
71
 
72
+ // ═══════════════════════════════════════════════════════════════
73
+ // CINEMATIC CAMERA SYSTEM
74
+ // ═══════════════════════════════════════════════════════════════
75
+ let cinematicMode = false;
76
+ let cinematicTarget = { x: 0, y: 0, z: 0 };
77
+ let cinematicCameraPos = { x: 6, y: -10, z: 8 };
78
+ let cameraAnimating = false;
79
+ let cinematicStartTime = 0;
80
+ let cinematicDuration = 1500; // ms for camera sweep
81
+ let cameraStartPos = { x: 0, y: 0, z: 0 };
82
+ let cameraEndPos = { x: 0, y: 0, z: 0 };
83
+ let targetStart = { x: 0, y: 0, z: 0 };
84
+ let targetEnd = { x: 0, y: 0, z: 0 };
85
+ let lastMoveFrom = null;
86
+ let lastMoveTo = null;
87
+
88
+ // Piece data storage for hover detection
89
+ const pieceMap = new Map(); // Maps piece mesh to {type, square, isWhite}
90
+
91
+ // Colors - SOLID MUTED PALETTE (no neon)
92
+ const BOARD_LIGHT = 0xC4A060; // Warm oak
93
+ const BOARD_DARK = 0x6B4423; // Dark walnut
94
+ const BOARD_EDGE = 0x3A2510; // Dark wood frame
95
+ const WHITE_PIECE = 0xE8E0D0; // Warm ivory
96
+ const BLACK_PIECE = 0x252525; // Charcoal
97
+ const GOLD = 0xB08020; // Muted bronze/gold
98
+ const CYAN = 0x306080; // Steel blue
99
+ const MAGENTA = 0x803050; // Burgundy
100
  const BG_COLOR = 0x0a0a0f;
101
 
102
  // Piece heights
 
136
  dirLight.position.set(5, 5, 10);
137
  scene.add(dirLight);
138
 
139
+ const rimLight = new THREE.DirectionalLight(0x405570, 0.3);
140
  rimLight.position.set(-5, -5, 5);
141
  scene.add(rimLight);
142
 
 
144
  boardGroup = new THREE.Group();
145
  piecesGroup = new THREE.Group();
146
  arcsGroup = new THREE.Group();
147
+ highlightGroup = new THREE.Group();
148
  scene.add(boardGroup);
149
  scene.add(piecesGroup);
150
  scene.add(arcsGroup);
151
+ scene.add(highlightGroup);
152
+
153
+ // Raycaster for hover detection
154
+ raycaster = new THREE.Raycaster();
155
+ mouse = new THREE.Vector2();
156
 
157
  // Create board
158
  createBoard();
 
166
  // Listen for messages from Dash
167
  window.addEventListener('message', handleMessage);
168
 
169
+ // Mouse move for hover detection
170
+ window.addEventListener('mousemove', onMouseMove);
171
+
172
  animate();
173
  }
174
 
 
305
  group.add(ball);
306
  }
307
 
308
+ // Add glow ring at base for style - MUTED COLORS
309
  const glowGeo = new THREE.RingGeometry(0.35, 0.45, 32);
310
  const glowMat = new THREE.MeshBasicMaterial({
311
+ color: isWhite ? 0x405060 : 0x604050,
312
  transparent: true,
313
+ opacity: 0.25,
314
  side: THREE.DoubleSide
315
  });
316
  const glow = new THREE.Mesh(glowGeo, glowMat);
 
319
  group.add(glow);
320
 
321
  group.position.set(x - 3.5, y - 3.5, 0);
322
+
323
+ // Store piece data for hover detection
324
+ const square = y * 8 + x;
325
+ group.userData = {
326
+ type: type.toLowerCase(),
327
+ isWhite: isWhite,
328
+ square: square,
329
+ file: x,
330
+ rank: y
331
+ };
332
+
333
  return group;
334
  }
335
 
 
357
  const thickness = isSelected ? (0.08 + prob * 0.06) : (0.03 + prob * 0.05);
358
  const tubeGeo = new THREE.TubeGeometry(curve, 20, thickness, 8, false);
359
 
360
+ // Color selection - SOLID MUTED COLORS
361
  let color;
362
  if (isSelected) {
363
+ color = 0xE0E0E0; // Off-white for selected
364
  } else if (isHuman) {
365
+ color = 0x2D5A3D; // Forest green for human moves
366
  } else if (isBlack) {
367
+ color = isCapture ? 0x8B2020 : (prob > 0.3 ? 0xA05020 : 0x704050);
368
  } else {
369
  color = isCapture ? MAGENTA : (prob > 0.3 ? GOLD : CYAN);
370
  }
 
419
 
420
  if (data.type === 'update') {
421
  if (data.fen) {
422
+ currentFEN = data.fen;
423
  updateFromFEN(data.fen);
424
  }
425
  if (data.candidates !== undefined) {
426
  updateCandidates(data.candidates);
427
  }
428
  }
429
+
430
+ // ═══════════════════════════════════════════════════════════════
431
+ // REPLAY OVERLAY - No camera movement, just show progress
432
+ // ═══════════════════════════════════════════════════════════════
433
+ if (data.type === 'cinematic_start') {
434
+ // Show replay overlay
435
+ const overlay = document.getElementById('replay-overlay');
436
+ if (overlay) overlay.style.display = 'block';
437
+ console.log('[REPLAY] Started');
438
+ }
439
+
440
+ if (data.type === 'cinematic_stop') {
441
+ // Hide replay overlay
442
+ const overlay = document.getElementById('replay-overlay');
443
+ if (overlay) overlay.style.display = 'none';
444
+ console.log('[REPLAY] Stopped');
445
+ }
446
+
447
+ if (data.type === 'cinematic_move') {
448
+ // Update replay overlay - NO camera movement
449
+ const moveNum = data.move_num || 1;
450
+ const totalMoves = data.total_moves || 1;
451
+ const moveName = data.move_name || '?';
452
+ const isCapture = data.is_capture || false;
453
+ const isCheck = data.is_check || false;
454
+
455
+ // Update overlay display
456
+ const moveEl = document.getElementById('replay-move');
457
+ const counterEl = document.getElementById('replay-counter');
458
+ const barEl = document.getElementById('replay-bar');
459
+
460
+ if (moveEl) {
461
+ // Style based on move type
462
+ let moveColor = '#80B0C0'; // Normal move
463
+ let prefix = '';
464
+ if (isCapture) { moveColor = '#C06060'; prefix = '⚔ '; }
465
+ if (isCheck) { moveColor = '#D0A030'; prefix = '♚ '; }
466
+ moveEl.textContent = prefix + moveName;
467
+ moveEl.style.color = moveColor;
468
+ }
469
+ if (counterEl) {
470
+ counterEl.textContent = `Move ${moveNum} / ${totalMoves}`;
471
+ }
472
+ if (barEl) {
473
+ barEl.style.width = `${(moveNum / totalMoves) * 100}%`;
474
+ }
475
+
476
+ console.log(`[REPLAY] Move ${moveNum}/${totalMoves}: ${moveName}`);
477
+ }
478
+ }
479
+
480
+ // ═══════════════════════════════════════════════════════════════
481
+ // SMOOTH CAMERA ANIMATION
482
+ // ═══════════════════════════════════════════════════════════════
483
+ function animateCameraTo(x, y, z, targetX, targetY, targetZ, duration) {
484
+ cameraStartPos = {
485
+ x: camera.position.x,
486
+ y: camera.position.y,
487
+ z: camera.position.z
488
+ };
489
+ cameraEndPos = { x, y, z };
490
+ targetStart = {
491
+ x: controls.target.x,
492
+ y: controls.target.y,
493
+ z: controls.target.z
494
+ };
495
+ targetEnd = { x: targetX, y: targetY, z: targetZ };
496
+ cinematicStartTime = performance.now();
497
+ cinematicDuration = duration;
498
+ cameraAnimating = true;
499
+ }
500
+
501
+ function updateCinematicCamera() {
502
+ if (!cameraAnimating) return;
503
+
504
+ const elapsed = performance.now() - cinematicStartTime;
505
+ const progress = Math.min(elapsed / cinematicDuration, 1);
506
+
507
+ // Smooth easing (ease-in-out cubic)
508
+ const ease = progress < 0.5
509
+ ? 4 * progress * progress * progress
510
+ : 1 - Math.pow(-2 * progress + 2, 3) / 2;
511
+
512
+ // Interpolate camera position
513
+ camera.position.x = cameraStartPos.x + (cameraEndPos.x - cameraStartPos.x) * ease;
514
+ camera.position.y = cameraStartPos.y + (cameraEndPos.y - cameraStartPos.y) * ease;
515
+ camera.position.z = cameraStartPos.z + (cameraEndPos.z - cameraStartPos.z) * ease;
516
+
517
+ // Interpolate target
518
+ controls.target.x = targetStart.x + (targetEnd.x - targetStart.x) * ease;
519
+ controls.target.y = targetStart.y + (targetEnd.y - targetStart.y) * ease;
520
+ controls.target.z = targetStart.z + (targetEnd.z - targetStart.z) * ease;
521
+
522
+ camera.lookAt(controls.target);
523
+
524
+ if (progress >= 1) {
525
+ cameraAnimating = false;
526
+ }
527
+ }
528
+
529
+ // Chess move generation helper
530
+ function getLegalMoves(pieceData) {
531
+ const { type, isWhite, file, rank } = pieceData;
532
+ const moves = [];
533
+ const pieceNames = {
534
+ 'p': 'Pawn', 'n': 'Knight', 'b': 'Bishop',
535
+ 'r': 'Rook', 'q': 'Queen', 'k': 'King'
536
+ };
537
+ const name = pieceNames[type] || 'Piece';
538
+ const colorName = isWhite ? 'White' : 'Black';
539
+ let specialInfo = [];
540
+
541
+ // Parse FEN to get board state
542
+ const fenParts = currentFEN.split(' ');
543
+ const position = fenParts[0];
544
+ const turn = fenParts[1] === 'w';
545
+ const castling = fenParts[2] || '-';
546
+ const enPassant = fenParts[3] || '-';
547
+
548
+ // Build board array from FEN
549
+ const board = [];
550
+ const rows = position.split('/');
551
+ for (let r = 7; r >= 0; r--) {
552
+ const row = [];
553
+ for (const char of rows[7 - r]) {
554
+ if (char >= '1' && char <= '8') {
555
+ for (let i = 0; i < parseInt(char); i++) row.push(null);
556
+ } else {
557
+ row.push({ type: char.toLowerCase(), isWhite: char === char.toUpperCase() });
558
+ }
559
+ }
560
+ board[r] = row;
561
+ }
562
+
563
+ const isBlocked = (f, r) => f < 0 || f > 7 || r < 0 || r > 7 || board[r]?.[f] !== null;
564
+ const isEnemy = (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 &&
565
+ board[r]?.[f] !== null && board[r][f].isWhite !== isWhite;
566
+ const isEmpty = (f, r) => f >= 0 && f <= 7 && r >= 0 && r <= 7 && board[r]?.[f] === null;
567
+
568
+ if (type === 'p') {
569
+ // Pawn moves
570
+ const dir = isWhite ? 1 : -1;
571
+ const startRank = isWhite ? 1 : 6;
572
+ const promoRank = isWhite ? 7 : 0;
573
+
574
+ // Forward one
575
+ if (isEmpty(file, rank + dir)) {
576
+ moves.push({ file: file, rank: rank + dir, capture: false });
577
+ // Forward two from start
578
+ if (rank === startRank && isEmpty(file, rank + dir * 2)) {
579
+ moves.push({ file: file, rank: rank + dir * 2, capture: false });
580
+ specialInfo.push("Can move 2 squares (first move)");
581
+ }
582
+ }
583
+
584
+ // Diagonal captures
585
+ for (const df of [-1, 1]) {
586
+ if (isEnemy(file + df, rank + dir)) {
587
+ moves.push({ file: file + df, rank: rank + dir, capture: true });
588
+ }
589
+ }
590
+
591
+ // En passant
592
+ if (enPassant !== '-') {
593
+ const epFile = enPassant.charCodeAt(0) - 97;
594
+ const epRank = parseInt(enPassant[1]) - 1;
595
+ if (Math.abs(epFile - file) === 1 && epRank === rank + dir) {
596
+ moves.push({ file: epFile, rank: epRank, capture: true, enPassant: true });
597
+ specialInfo.push("En passant available!");
598
+ }
599
+ }
600
+
601
+ // Promotion check
602
+ if (rank + dir === promoRank && moves.some(m => m.rank === promoRank)) {
603
+ specialInfo.push("Promotes to Q/R/B/N on next rank");
604
+ }
605
+
606
+ } else if (type === 'n') {
607
+ // Knight moves
608
+ const knightMoves = [[-2,-1],[-2,1],[-1,-2],[-1,2],[1,-2],[1,2],[2,-1],[2,1]];
609
+ for (const [df, dr] of knightMoves) {
610
+ const nf = file + df, nr = rank + dr;
611
+ if (nf >= 0 && nf <= 7 && nr >= 0 && nr <= 7) {
612
+ if (isEmpty(nf, nr) || isEnemy(nf, nr)) {
613
+ moves.push({ file: nf, rank: nr, capture: isEnemy(nf, nr) });
614
+ }
615
+ }
616
+ }
617
+
618
+ } else if (type === 'b') {
619
+ // Bishop moves (diagonals)
620
+ for (const [df, dr] of [[1,1],[1,-1],[-1,1],[-1,-1]]) {
621
+ for (let i = 1; i < 8; i++) {
622
+ const nf = file + df * i, nr = rank + dr * i;
623
+ if (nf < 0 || nf > 7 || nr < 0 || nr > 7) break;
624
+ if (isEmpty(nf, nr)) {
625
+ moves.push({ file: nf, rank: nr, capture: false });
626
+ } else if (isEnemy(nf, nr)) {
627
+ moves.push({ file: nf, rank: nr, capture: true });
628
+ break;
629
+ } else break;
630
+ }
631
+ }
632
+
633
+ } else if (type === 'r') {
634
+ // Rook moves (straight lines)
635
+ for (const [df, dr] of [[1,0],[-1,0],[0,1],[0,-1]]) {
636
+ for (let i = 1; i < 8; i++) {
637
+ const nf = file + df * i, nr = rank + dr * i;
638
+ if (nf < 0 || nf > 7 || nr < 0 || nr > 7) break;
639
+ if (isEmpty(nf, nr)) {
640
+ moves.push({ file: nf, rank: nr, capture: false });
641
+ } else if (isEnemy(nf, nr)) {
642
+ moves.push({ file: nf, rank: nr, capture: true });
643
+ break;
644
+ } else break;
645
+ }
646
+ }
647
+
648
+ } else if (type === 'q') {
649
+ // Queen moves (diagonals + straight)
650
+ for (const [df, dr] of [[1,1],[1,-1],[-1,1],[-1,-1],[1,0],[-1,0],[0,1],[0,-1]]) {
651
+ for (let i = 1; i < 8; i++) {
652
+ const nf = file + df * i, nr = rank + dr * i;
653
+ if (nf < 0 || nf > 7 || nr < 0 || nr > 7) break;
654
+ if (isEmpty(nf, nr)) {
655
+ moves.push({ file: nf, rank: nr, capture: false });
656
+ } else if (isEnemy(nf, nr)) {
657
+ moves.push({ file: nf, rank: nr, capture: true });
658
+ break;
659
+ } else break;
660
+ }
661
+ }
662
+
663
+ } else if (type === 'k') {
664
+ // King moves
665
+ for (let df = -1; df <= 1; df++) {
666
+ for (let dr = -1; dr <= 1; dr++) {
667
+ if (df === 0 && dr === 0) continue;
668
+ const nf = file + df, nr = rank + dr;
669
+ if (nf >= 0 && nf <= 7 && nr >= 0 && nr <= 7) {
670
+ if (isEmpty(nf, nr) || isEnemy(nf, nr)) {
671
+ moves.push({ file: nf, rank: nr, capture: isEnemy(nf, nr) });
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ // Castling
678
+ if (isWhite && castling.includes('K') && isEmpty(5, 0) && isEmpty(6, 0)) {
679
+ moves.push({ file: 6, rank: 0, capture: false, castle: 'kingside' });
680
+ specialInfo.push("Kingside castle available (O-O)");
681
+ }
682
+ if (isWhite && castling.includes('Q') && isEmpty(1, 0) && isEmpty(2, 0) && isEmpty(3, 0)) {
683
+ moves.push({ file: 2, rank: 0, capture: false, castle: 'queenside' });
684
+ specialInfo.push("Queenside castle available (O-O-O)");
685
+ }
686
+ if (!isWhite && castling.includes('k') && isEmpty(5, 7) && isEmpty(6, 7)) {
687
+ moves.push({ file: 6, rank: 7, capture: false, castle: 'kingside' });
688
+ specialInfo.push("Kingside castle available (O-O)");
689
+ }
690
+ if (!isWhite && castling.includes('q') && isEmpty(1, 7) && isEmpty(2, 7) && isEmpty(3, 7)) {
691
+ moves.push({ file: 2, rank: 7, capture: false, castle: 'queenside' });
692
+ specialInfo.push("Queenside castle available (O-O-O)");
693
+ }
694
+ }
695
+
696
+ return { name, colorName, moves, specialInfo };
697
+ }
698
+
699
+ function createHighlightSquare(file, rank, isCapture, isSpecial) {
700
+ const geo = new THREE.RingGeometry(0.35, 0.45, 32);
701
+ const color = isCapture ? 0xA03070 : (isSpecial ? 0xD4A020 : 0x2090B0);
702
+ const mat = new THREE.MeshBasicMaterial({
703
+ color: color,
704
+ transparent: true,
705
+ opacity: 0.7,
706
+ side: THREE.DoubleSide
707
+ });
708
+ const ring = new THREE.Mesh(geo, mat);
709
+ ring.position.set(file - 3.5, rank - 3.5, 0.02);
710
+ return ring;
711
+ }
712
+
713
+ function showLegalMoves(pieceData) {
714
+ // Clear existing highlights
715
+ while (highlightGroup.children.length) highlightGroup.remove(highlightGroup.children[0]);
716
+
717
+ const { name, colorName, moves, specialInfo } = getLegalMoves(pieceData);
718
+
719
+ // Create highlight rings for each legal move
720
+ for (const move of moves) {
721
+ const highlight = createHighlightSquare(
722
+ move.file,
723
+ move.rank,
724
+ move.capture,
725
+ move.castle || move.enPassant
726
+ );
727
+ highlightGroup.add(highlight);
728
+ }
729
+
730
+ // Update tooltip
731
+ const tooltip = document.getElementById('piece-tooltip');
732
+ const captures = moves.filter(m => m.capture).length;
733
+ const quietMoves = moves.filter(m => !m.capture).length;
734
+
735
+ let html = `<div class="piece-name">${colorName} ${name}</div>`;
736
+ html += `<div class="move-count">${moves.length} legal moves</div>`;
737
+ if (quietMoves > 0) html += `<div>• ${quietMoves} quiet moves</div>`;
738
+ if (captures > 0) html += `<div style="color:#906070">• ${captures} captures</div>`;
739
+ for (const info of specialInfo) {
740
+ html += `<div class="special">★ ${info}</div>`;
741
+ }
742
+
743
+ tooltip.innerHTML = html;
744
+ tooltip.style.display = 'block';
745
+ }
746
+
747
+ function hideTooltip() {
748
+ document.getElementById('piece-tooltip').style.display = 'none';
749
+ while (highlightGroup.children.length) highlightGroup.remove(highlightGroup.children[0]);
750
+ }
751
+
752
+ function onMouseMove(event) {
753
+ mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
754
+ mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
755
+
756
+ // Update tooltip position
757
+ const tooltip = document.getElementById('piece-tooltip');
758
+ tooltip.style.left = (event.clientX + 15) + 'px';
759
+ tooltip.style.top = (event.clientY + 15) + 'px';
760
+
761
+ // Raycast to find hovered piece
762
+ raycaster.setFromCamera(mouse, camera);
763
+ const intersects = raycaster.intersectObjects(piecesGroup.children, true);
764
+
765
+ if (intersects.length > 0) {
766
+ // Find the parent group with userData
767
+ let piece = intersects[0].object;
768
+ while (piece && !piece.userData?.type && piece.parent) {
769
+ piece = piece.parent;
770
+ }
771
+
772
+ if (piece?.userData?.type) {
773
+ if (hoveredPiece !== piece) {
774
+ hoveredPiece = piece;
775
+ showLegalMoves(piece.userData);
776
+ }
777
+ } else {
778
+ if (hoveredPiece) {
779
+ hoveredPiece = null;
780
+ hideTooltip();
781
+ }
782
+ }
783
+ } else {
784
+ if (hoveredPiece) {
785
+ hoveredPiece = null;
786
+ hideTooltip();
787
+ }
788
+ }
789
  }
790
 
791
  function onResize() {
 
796
 
797
  function animate() {
798
  requestAnimationFrame(animate);
799
+
800
+ // Update cinematic camera if animating
801
+ updateCinematicCamera();
802
+
803
  controls.update();
804
  renderer.render(scene, camera);
805
  }
src/streamlit_app_3d.py ADDED
@@ -0,0 +1,1065 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CASCADE-LATTICE Chess
3
+ =====================
4
+ A chess match with HOLD - pause mid-game to see and select moves.
5
+ """
6
+
7
+ import streamlit as st
8
+ import chess
9
+ import chess.engine
10
+ import numpy as np
11
+ import plotly.graph_objects as go
12
+ import time
13
+ import platform
14
+ import shutil
15
+ from typing import List, Optional, Any
16
+ from dataclasses import dataclass, field
17
+
18
+ # CASCADE-LATTICE
19
+ try:
20
+ from cascade import Hold, CausationGraph, Tracer, MetricsEngine
21
+ CASCADE_AVAILABLE = True
22
+ except ImportError:
23
+ CASCADE_AVAILABLE = False
24
+ Hold = None
25
+
26
+ # Stockfish - check local folder first, then system
27
+ import os
28
+ from pathlib import Path
29
+
30
+ # Get project root (parent of src/)
31
+ PROJECT_ROOT = Path(__file__).parent.parent.resolve()
32
+ LOCAL_STOCKFISH = PROJECT_ROOT / "stockfish" / "stockfish-windows-x86-64-avx2.exe"
33
+
34
+ if LOCAL_STOCKFISH.exists():
35
+ STOCKFISH_PATH = str(LOCAL_STOCKFISH.resolve())
36
+ elif shutil.which("stockfish"):
37
+ STOCKFISH_PATH = shutil.which("stockfish")
38
+ else:
39
+ STOCKFISH_PATH = "/usr/games/stockfish" # Linux fallback
40
+
41
+ print(f"[STOCKFISH] Path: {STOCKFISH_PATH}")
42
+
43
+ # ═══════════════════════════════════════════════════════════════════════════════
44
+ # VISUAL THEME - Polished 3D Chess
45
+ # ═══════════════════════════════════════════════════════════════════════════════
46
+
47
+ # Board colors
48
+ BOARD_LIGHT = '#8B7355' # Warm wood light
49
+ BOARD_DARK = '#4A3728' # Rich wood dark
50
+ BOARD_BORDER = '#2A1F14' # Dark frame
51
+
52
+ # Piece colors (metallic feel)
53
+ WHITE_PIECE = '#F5F5DC' # Ivory/cream
54
+ WHITE_ACCENT = '#FFD700' # Gold trim
55
+ BLACK_PIECE = '#1C1C1C' # Obsidian
56
+ BLACK_ACCENT = '#8B0000' # Dark red trim
57
+
58
+ # Arc colors (energy trails)
59
+ ARC_WHITE = '#00FFAA' # Cyan-green energy
60
+ ARC_BLACK = '#FF3366' # Crimson energy
61
+
62
+ # UI colors
63
+ GRID_COLOR = '#333344'
64
+ BG_COLOR = '#0D0D12'
65
+
66
+ # Piece heights (taller = more important)
67
+ PIECE_HEIGHTS = {
68
+ chess.PAWN: 0.4,
69
+ chess.KNIGHT: 0.7,
70
+ chess.BISHOP: 0.8,
71
+ chess.ROOK: 0.6,
72
+ chess.QUEEN: 1.0,
73
+ chess.KING: 1.1
74
+ }
75
+
76
+ # Piece sizes (marker size)
77
+ PIECE_SIZES = {
78
+ chess.PAWN: 8,
79
+ chess.KNIGHT: 11,
80
+ chess.BISHOP: 11,
81
+ chess.ROOK: 10,
82
+ chess.QUEEN: 13,
83
+ chess.KING: 14
84
+ }
85
+
86
+ @dataclass
87
+ class MoveCandidate:
88
+ move: str # UCI notation
89
+ prob: float # Probability/weight
90
+ value: float # Evaluation score
91
+ from_sq: int # Source square (0-63)
92
+ to_sq: int # Target square (0-63)
93
+ is_capture: bool = False
94
+ is_check: bool = False
95
+ is_castle: bool = False
96
+ captured_piece: Optional[str] = None
97
+ move_type: str = 'quiet' # quiet, capture, check, castle, promotion
98
+
99
+ @dataclass
100
+ class BoardAnalysis:
101
+ """Analysis data for visualization."""
102
+ material_white: int = 0
103
+ material_black: int = 0
104
+ eval_score: float = 0.0
105
+ white_attacks: List[int] = field(default_factory=list) # squares white attacks
106
+ black_attacks: List[int] = field(default_factory=list) # squares black attacks
107
+ tension_squares: List[int] = field(default_factory=list) # contested squares
108
+ king_safety_white: float = 0.0
109
+ king_safety_black: float = 0.0
110
+
111
+ # ═══════════════════════════════════════════════════════════════════════════════
112
+ # 3D BOARD VISUALIZATION
113
+ # ═══════════════════════════════════════════════════════════════════════════════
114
+
115
+ def square_to_3d(square: int) -> tuple:
116
+ """Convert chess square (0-63) to 3D coordinates.
117
+ Board is on Z=0 plane, centered at origin."""
118
+ file = square % 8 # 0-7 (a-h)
119
+ rank = square // 8 # 0-7 (1-8)
120
+ x = file - 3.5 # Center at 0
121
+ y = rank - 3.5 # Center at 0
122
+ z = 0 # Board plane
123
+ return (x, y, z)
124
+
125
+ def analyze_board(board: chess.Board) -> BoardAnalysis:
126
+ """Analyze board state for visualization."""
127
+ piece_values = {chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
128
+ chess.ROOK: 5, chess.QUEEN: 9, chess.KING: 0}
129
+
130
+ analysis = BoardAnalysis()
131
+
132
+ # Material count
133
+ for square in chess.SQUARES:
134
+ piece = board.piece_at(square)
135
+ if piece:
136
+ val = piece_values.get(piece.piece_type, 0)
137
+ if piece.color == chess.WHITE:
138
+ analysis.material_white += val
139
+ else:
140
+ analysis.material_black += val
141
+
142
+ # Attack maps
143
+ for square in chess.SQUARES:
144
+ white_attackers = board.attackers(chess.WHITE, square)
145
+ black_attackers = board.attackers(chess.BLACK, square)
146
+
147
+ if white_attackers:
148
+ analysis.white_attacks.append(square)
149
+ if black_attackers:
150
+ analysis.black_attacks.append(square)
151
+ if white_attackers and black_attackers:
152
+ analysis.tension_squares.append(square)
153
+
154
+ return analysis
155
+
156
+ def create_arc_trajectory(from_sq: int, to_sq: int, is_white: bool, num_points: int = 30) -> dict:
157
+ """Create an arc trajectory for a move.
158
+ White arcs go UP (positive Z), Black arcs go DOWN (negative Z)."""
159
+
160
+ x1, y1, _ = square_to_3d(from_sq)
161
+ x2, y2, _ = square_to_3d(to_sq)
162
+
163
+ # Calculate arc height based on move distance
164
+ distance = np.sqrt((x2-x1)**2 + (y2-y1)**2)
165
+ arc_height = max(1.5, distance * 0.8) # Minimum height of 1.5
166
+
167
+ # Direction: white goes up, black goes down
168
+ direction = 1 if is_white else -1
169
+
170
+ # Generate arc points
171
+ t = np.linspace(0, 1, num_points)
172
+
173
+ # Parabolic arc
174
+ x = x1 + (x2 - x1) * t
175
+ y = y1 + (y2 - y1) * t
176
+ z = direction * arc_height * 4 * t * (1 - t) # Parabola peaking at t=0.5
177
+
178
+ return {'x': x, 'y': y, 'z': z}
179
+
180
+ def create_board_surface() -> List[go.Mesh3d]:
181
+ """Create the chess board as 3D mesh tiles with depth."""
182
+ traces = []
183
+
184
+ tile_size = 0.48 # Slightly smaller than 0.5 for gaps
185
+ tile_height = 0.08 # Thickness of tiles
186
+
187
+ for rank in range(8):
188
+ for file in range(8):
189
+ cx = file - 3.5
190
+ cy = rank - 3.5
191
+ is_light = (rank + file) % 2 == 1
192
+ color = BOARD_LIGHT if is_light else BOARD_DARK
193
+
194
+ # Create a 3D tile (box) for each square
195
+ # 8 vertices of a box
196
+ x = [cx-tile_size, cx+tile_size, cx+tile_size, cx-tile_size,
197
+ cx-tile_size, cx+tile_size, cx+tile_size, cx-tile_size]
198
+ y = [cy-tile_size, cy-tile_size, cy+tile_size, cy+tile_size,
199
+ cy-tile_size, cy-tile_size, cy+tile_size, cy+tile_size]
200
+ z = [0, 0, 0, 0,
201
+ tile_height, tile_height, tile_height, tile_height]
202
+
203
+ # Define faces using vertex indices
204
+ i = [0, 0, 4, 4, 0, 1] # triangles
205
+ j = [1, 2, 5, 6, 4, 5]
206
+ k = [2, 3, 6, 7, 1, 2]
207
+
208
+ traces.append(go.Mesh3d(
209
+ x=x, y=y, z=z,
210
+ i=[0, 0, 4, 4, 0, 1, 2, 3, 4, 5, 0, 3],
211
+ j=[1, 2, 5, 6, 4, 5, 6, 7, 5, 6, 1, 7],
212
+ k=[2, 3, 6, 7, 1, 2, 3, 4, 7, 7, 4, 4],
213
+ color=color,
214
+ opacity=0.95,
215
+ flatshading=True,
216
+ hoverinfo='skip',
217
+ showlegend=False
218
+ ))
219
+
220
+ # Add board frame/border
221
+ frame_traces = create_board_frame()
222
+ traces.extend(frame_traces)
223
+
224
+ return traces
225
+
226
+ def create_board_frame() -> List[go.Scatter3d]:
227
+ """Create decorative frame around the board."""
228
+ traces = []
229
+
230
+ # Outer border lines
231
+ border = 4.2
232
+ z_line = 0.04
233
+
234
+ # Frame corners
235
+ corners_x = [-border, border, border, -border, -border]
236
+ corners_y = [-border, -border, border, border, -border]
237
+ corners_z = [z_line] * 5
238
+
239
+ traces.append(go.Scatter3d(
240
+ x=corners_x, y=corners_y, z=corners_z,
241
+ mode='lines',
242
+ line=dict(color=WHITE_ACCENT, width=3),
243
+ hoverinfo='skip',
244
+ showlegend=False
245
+ ))
246
+
247
+ # File labels (a-h)
248
+ for i, label in enumerate('abcdefgh'):
249
+ traces.append(go.Scatter3d(
250
+ x=[i - 3.5], y=[-4.5], z=[0.1],
251
+ mode='text',
252
+ text=[label],
253
+ textfont=dict(size=10, color='#888899'),
254
+ hoverinfo='skip',
255
+ showlegend=False
256
+ ))
257
+
258
+ # Rank labels (1-8)
259
+ for i in range(8):
260
+ traces.append(go.Scatter3d(
261
+ x=[-4.5], y=[i - 3.5], z=[0.1],
262
+ mode='text',
263
+ text=[str(i + 1)],
264
+ textfont=dict(size=10, color='#888899'),
265
+ hoverinfo='skip',
266
+ showlegend=False
267
+ ))
268
+
269
+ return traces
270
+
271
+ def create_piece_markers(board: chess.Board) -> List[go.Scatter3d]:
272
+ """Create 3D piece columns - taller pieces = more important."""
273
+ traces = []
274
+
275
+ piece_symbols = {
276
+ chess.PAWN: '♟', chess.KNIGHT: '♞', chess.BISHOP: '♝',
277
+ chess.ROOK: '♜', chess.QUEEN: '♛', chess.KING: '♚'
278
+ }
279
+
280
+ # Group pieces by type for better rendering
281
+ for piece_type in [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN, chess.KING]:
282
+ white_x, white_y, white_z = [], [], []
283
+ black_x, black_y, black_z = [], [], []
284
+
285
+ height = PIECE_HEIGHTS[piece_type]
286
+ size = PIECE_SIZES[piece_type]
287
+ symbol = piece_symbols[piece_type]
288
+
289
+ for square in chess.SQUARES:
290
+ piece = board.piece_at(square)
291
+ if piece and piece.piece_type == piece_type:
292
+ x, y, _ = square_to_3d(square)
293
+
294
+ if piece.color == chess.WHITE:
295
+ white_x.append(x)
296
+ white_y.append(y)
297
+ white_z.append(height)
298
+ else:
299
+ black_x.append(x)
300
+ black_y.append(y)
301
+ black_z.append(height)
302
+
303
+ # White pieces - ivory with gold accent
304
+ if white_x:
305
+ # Base marker
306
+ traces.append(go.Scatter3d(
307
+ x=white_x, y=white_y, z=[0.1] * len(white_x),
308
+ mode='markers',
309
+ marker=dict(size=size + 4, color=WHITE_ACCENT, symbol='circle', opacity=0.6),
310
+ hoverinfo='skip',
311
+ showlegend=False
312
+ ))
313
+ # Main piece body
314
+ traces.append(go.Scatter3d(
315
+ x=white_x, y=white_y, z=white_z,
316
+ mode='markers+text',
317
+ marker=dict(
318
+ size=size,
319
+ color=WHITE_PIECE,
320
+ symbol='diamond',
321
+ opacity=0.95,
322
+ line=dict(color=WHITE_ACCENT, width=2)
323
+ ),
324
+ text=[symbol] * len(white_x),
325
+ textfont=dict(size=12, color='#222222'),
326
+ textposition='top center',
327
+ name=f'White {chess.piece_name(piece_type).title()}',
328
+ hovertemplate=f'{chess.piece_name(piece_type).title()}<extra>White</extra>',
329
+ customdata=[square for square in chess.SQUARES
330
+ if board.piece_at(square) and
331
+ board.piece_at(square).piece_type == piece_type and
332
+ board.piece_at(square).color == chess.WHITE]
333
+ ))
334
+ # Vertical line (piece stem)
335
+ for i, (px, py, pz) in enumerate(zip(white_x, white_y, white_z)):
336
+ traces.append(go.Scatter3d(
337
+ x=[px, px], y=[py, py], z=[0.1, pz],
338
+ mode='lines',
339
+ line=dict(color=WHITE_PIECE, width=3),
340
+ hoverinfo='skip',
341
+ showlegend=False
342
+ ))
343
+
344
+ # Black pieces - obsidian with red accent
345
+ if black_x:
346
+ # Base marker
347
+ traces.append(go.Scatter3d(
348
+ x=black_x, y=black_y, z=[0.1] * len(black_x),
349
+ mode='markers',
350
+ marker=dict(size=size + 4, color=BLACK_ACCENT, symbol='circle', opacity=0.6),
351
+ hoverinfo='skip',
352
+ showlegend=False
353
+ ))
354
+ # Main piece body
355
+ traces.append(go.Scatter3d(
356
+ x=black_x, y=black_y, z=black_z,
357
+ mode='markers+text',
358
+ marker=dict(
359
+ size=size,
360
+ color=BLACK_PIECE,
361
+ symbol='diamond',
362
+ opacity=0.95,
363
+ line=dict(color=BLACK_ACCENT, width=2)
364
+ ),
365
+ text=[symbol] * len(black_x),
366
+ textfont=dict(size=12, color='#EEEEEE'),
367
+ textposition='top center',
368
+ name=f'Black {chess.piece_name(piece_type).title()}',
369
+ hovertemplate=f'{chess.piece_name(piece_type).title()}<extra>Black</extra>',
370
+ customdata=[square for square in chess.SQUARES
371
+ if board.piece_at(square) and
372
+ board.piece_at(square).piece_type == piece_type and
373
+ board.piece_at(square).color == chess.BLACK]
374
+ ))
375
+ # Vertical line (piece stem)
376
+ for i, (px, py, pz) in enumerate(zip(black_x, black_y, black_z)):
377
+ traces.append(go.Scatter3d(
378
+ x=[px, px], y=[py, py], z=[0.1, pz],
379
+ mode='lines',
380
+ line=dict(color=BLACK_PIECE, width=3),
381
+ hoverinfo='skip',
382
+ showlegend=False
383
+ ))
384
+
385
+ return traces
386
+
387
+ def create_move_arcs(candidates: List[MoveCandidate], is_white: bool) -> List[go.Scatter3d]:
388
+ """Create arc trajectories for candidate moves - styled by move type."""
389
+ traces = []
390
+
391
+ # Color schemes by move type
392
+ type_colors = {
393
+ 'quiet': '#00FFAA' if is_white else '#FF3366',
394
+ 'capture': '#FF6600', # Orange for captures
395
+ 'check': '#FFFF00', # Yellow for checks
396
+ 'check+capture': '#FF0000', # Red for check+capture
397
+ 'castle': '#00AAFF', # Blue for castling
398
+ 'promotion': '#FF00FF' # Magenta for promotion
399
+ }
400
+
401
+ for i, candidate in enumerate(candidates):
402
+ arc = create_arc_trajectory(
403
+ candidate.from_sq,
404
+ candidate.to_sq,
405
+ is_white
406
+ )
407
+
408
+ # Get color based on move type
409
+ base_color = type_colors.get(candidate.move_type, type_colors['quiet'])
410
+ # Fade color based on rank
411
+ opacity = max(0.4, min(1.0, candidate.prob * 1.5))
412
+
413
+ # Line thickness based on probability
414
+ width = max(3, candidate.prob * 15)
415
+
416
+ # Special effects for tactical moves
417
+ if candidate.is_capture or candidate.is_check:
418
+ width += 2 # Thicker for tactical moves
419
+
420
+ # Outer glow
421
+ traces.append(go.Scatter3d(
422
+ x=arc['x'], y=arc['y'], z=arc['z'],
423
+ mode='lines',
424
+ line=dict(color=base_color, width=width + 4),
425
+ opacity=opacity * 0.3,
426
+ hoverinfo='skip',
427
+ showlegend=False
428
+ ))
429
+
430
+ # Core line
431
+ move_label = candidate.move
432
+ if candidate.is_capture:
433
+ move_label += f" ×{candidate.captured_piece or '?'}"
434
+ if candidate.is_check:
435
+ move_label += " +"
436
+
437
+ traces.append(go.Scatter3d(
438
+ x=arc['x'], y=arc['y'], z=arc['z'],
439
+ mode='lines',
440
+ line=dict(color=base_color, width=width),
441
+ opacity=opacity,
442
+ name=f"{move_label} ({candidate.prob*100:.0f}%)",
443
+ hovertemplate=f"<b>{move_label}</b><br>Type: {candidate.move_type}<br>Prob: {candidate.prob*100:.1f}%<br>Eval: {candidate.value:+.2f}<extra></extra>"
444
+ ))
445
+
446
+ # Destination marker - different shapes by type
447
+ x2, y2, _ = square_to_3d(candidate.to_sq)
448
+ dest_symbol = 'diamond'
449
+ dest_size = 8
450
+ if candidate.is_capture:
451
+ dest_symbol = 'x'
452
+ dest_size = 12
453
+ elif candidate.is_check:
454
+ dest_symbol = 'diamond-open'
455
+ dest_size = 14
456
+
457
+ traces.append(go.Scatter3d(
458
+ x=[x2], y=[y2], z=[arc['z'][-1]],
459
+ mode='markers',
460
+ marker=dict(
461
+ size=dest_size,
462
+ color=base_color,
463
+ symbol=dest_symbol,
464
+ opacity=opacity,
465
+ line=dict(color='white', width=1)
466
+ ),
467
+ hoverinfo='skip',
468
+ showlegend=False
469
+ ))
470
+
471
+ # For captures, add a "falling" line to show what's being taken
472
+ if candidate.is_capture:
473
+ traces.append(go.Scatter3d(
474
+ x=[x2, x2], y=[y2, y2], z=[arc['z'][-1], -1.5],
475
+ mode='lines',
476
+ line=dict(color='#FF4444', width=2, dash='dash'),
477
+ opacity=0.5,
478
+ hoverinfo='skip',
479
+ showlegend=False
480
+ ))
481
+
482
+ return traces
483
+
484
+ def create_threat_visualization(board: chess.Board, analysis: BoardAnalysis) -> List[go.Scatter3d]:
485
+ """Create visualization of threats BELOW the board (-Z space).
486
+ Shows what the opponent is attacking/threatening."""
487
+ traces = []
488
+
489
+ is_white_turn = board.turn == chess.WHITE
490
+ # Show opponent's attacks (threats TO the current player)
491
+ threat_squares = analysis.black_attacks if is_white_turn else analysis.white_attacks
492
+ threat_color = ARC_BLACK if is_white_turn else ARC_WHITE
493
+
494
+ if not threat_squares:
495
+ return traces
496
+
497
+ # Create threat markers below board
498
+ threat_x, threat_y, threat_z = [], [], []
499
+ for sq in threat_squares[:20]: # Limit to avoid clutter
500
+ x, y, _ = square_to_3d(sq)
501
+ threat_x.append(x)
502
+ threat_y.append(y)
503
+ threat_z.append(-0.5) # Below board
504
+
505
+ traces.append(go.Scatter3d(
506
+ x=threat_x, y=threat_y, z=threat_z,
507
+ mode='markers',
508
+ marker=dict(
509
+ size=6,
510
+ color=threat_color,
511
+ symbol='x',
512
+ opacity=0.4
513
+ ),
514
+ name='Opponent Threats',
515
+ hoverinfo='skip',
516
+ showlegend=False
517
+ ))
518
+
519
+ return traces
520
+
521
+ def create_tension_zones(analysis: BoardAnalysis) -> List[go.Scatter3d]:
522
+ """Create visualization of contested/tension squares."""
523
+ traces = []
524
+
525
+ if not analysis.tension_squares:
526
+ return traces
527
+
528
+ # Tension squares glow at board level
529
+ tension_x, tension_y, tension_z = [], [], []
530
+ for sq in analysis.tension_squares:
531
+ x, y, _ = square_to_3d(sq)
532
+ tension_x.append(x)
533
+ tension_y.append(y)
534
+ tension_z.append(0.15)
535
+
536
+ traces.append(go.Scatter3d(
537
+ x=tension_x, y=tension_y, z=tension_z,
538
+ mode='markers',
539
+ marker=dict(
540
+ size=20,
541
+ color='#FFAA00', # Orange glow
542
+ symbol='square',
543
+ opacity=0.3
544
+ ),
545
+ name='Tension Zones',
546
+ hovertemplate='Contested Square<extra></extra>',
547
+ showlegend=False
548
+ ))
549
+
550
+ return traces
551
+
552
+ def create_info_panel(board: chess.Board, analysis: BoardAnalysis, eval_score: float) -> List[go.Scatter3d]:
553
+ """Create floating info panels around the board."""
554
+ traces = []
555
+
556
+ # Material balance (right side of board)
557
+ material_diff = analysis.material_white - analysis.material_black
558
+ mat_text = f"+{material_diff}" if material_diff > 0 else str(material_diff)
559
+ mat_color = '#00FF88' if material_diff > 0 else '#FF4466' if material_diff < 0 else '#888888'
560
+
561
+ traces.append(go.Scatter3d(
562
+ x=[5.5], y=[0], z=[1.5],
563
+ mode='text',
564
+ text=[f"MAT\\n{mat_text}"],
565
+ textfont=dict(size=12, color=mat_color),
566
+ hoverinfo='skip',
567
+ showlegend=False
568
+ ))
569
+
570
+ # Evaluation (left side)
571
+ eval_text = f"+{eval_score:.1f}" if eval_score > 0 else f"{eval_score:.1f}"
572
+ eval_color = '#00FF88' if eval_score > 0.2 else '#FF4466' if eval_score < -0.2 else '#888888'
573
+
574
+ traces.append(go.Scatter3d(
575
+ x=[-5.5], y=[0], z=[1.5],
576
+ mode='text',
577
+ text=[f"EVAL\\n{eval_text}"],
578
+ textfont=dict(size=12, color=eval_color),
579
+ hoverinfo='skip',
580
+ showlegend=False
581
+ ))
582
+
583
+ # Turn indicator (top)
584
+ turn_text = "WHITE" if board.turn == chess.WHITE else "BLACK"
585
+ turn_color = '#F5F5DC' if board.turn == chess.WHITE else '#666666'
586
+
587
+ traces.append(go.Scatter3d(
588
+ x=[0], y=[5], z=[0.5],
589
+ mode='text',
590
+ text=[f"► {turn_text}"],
591
+ textfont=dict(size=10, color=turn_color),
592
+ hoverinfo='skip',
593
+ showlegend=False
594
+ ))
595
+
596
+ # Move count
597
+ move_num = board.fullmove_number
598
+ traces.append(go.Scatter3d(
599
+ x=[0], y=[-5], z=[0.5],
600
+ mode='text',
601
+ text=[f"Move {move_num}"],
602
+ textfont=dict(size=10, color='#666666'),
603
+ hoverinfo='skip',
604
+ showlegend=False
605
+ ))
606
+
607
+ return traces
608
+
609
+ def create_3d_chess_figure(board: chess.Board, candidates: List[MoveCandidate] = None,
610
+ show_threats: bool = True, show_tension: bool = True) -> go.Figure:
611
+ """Create the full 3D chess visualization with information layers."""
612
+
613
+ fig = go.Figure()
614
+
615
+ # Analyze board state
616
+ analysis = analyze_board(board)
617
+ eval_score = candidates[0].value if candidates else 0.0
618
+
619
+ # Add board squares
620
+ for trace in create_board_surface():
621
+ fig.add_trace(trace)
622
+
623
+ # Add tension zones (contested squares - orange glow)
624
+ if show_tension:
625
+ for trace in create_tension_zones(analysis):
626
+ fig.add_trace(trace)
627
+
628
+ # Add pieces
629
+ for trace in create_piece_markers(board):
630
+ fig.add_trace(trace)
631
+
632
+ # Add threats below board (-Z space)
633
+ if show_threats:
634
+ for trace in create_threat_visualization(board, analysis):
635
+ fig.add_trace(trace)
636
+
637
+ # Add move arcs ABOVE board (+Z space) - candidates for current player
638
+ if candidates:
639
+ is_white = board.turn == chess.WHITE
640
+ for trace in create_move_arcs(candidates, is_white):
641
+ fig.add_trace(trace)
642
+
643
+ # Add floating info panels
644
+ for trace in create_info_panel(board, analysis, eval_score):
645
+ fig.add_trace(trace)
646
+
647
+ # Layout - cinematic chess view
648
+ fig.update_layout(
649
+ scene=dict(
650
+ xaxis=dict(
651
+ range=[-6.5, 6.5],
652
+ showbackground=False,
653
+ showgrid=False,
654
+ title='',
655
+ showticklabels=False,
656
+ showline=False,
657
+ zeroline=False
658
+ ),
659
+ yaxis=dict(
660
+ range=[-6.5, 6.5],
661
+ showbackground=False,
662
+ showgrid=False,
663
+ title='',
664
+ showticklabels=False,
665
+ showline=False,
666
+ zeroline=False
667
+ ),
668
+ zaxis=dict(
669
+ range=[-2, 4],
670
+ showbackground=False,
671
+ showgrid=False,
672
+ title='',
673
+ showticklabels=False,
674
+ showline=False,
675
+ zeroline=False
676
+ ),
677
+ aspectmode='manual',
678
+ aspectratio=dict(x=1, y=1, z=0.5),
679
+ camera=dict(
680
+ eye=dict(x=1.3, y=-1.3, z=1.0), # Classic chess view angle
681
+ up=dict(x=0, y=0, z=1),
682
+ center=dict(x=0, y=0, z=0)
683
+ ),
684
+ bgcolor=BG_COLOR
685
+ ),
686
+ paper_bgcolor=BG_COLOR,
687
+ plot_bgcolor=BG_COLOR,
688
+ margin=dict(l=0, r=0, t=30, b=0),
689
+ showlegend=False,
690
+ height=650
691
+ )
692
+
693
+ return fig
694
+
695
+ # ═══════════════════════════════════════════════════════════════════════════════
696
+ # CASCADE HOLD SYSTEM
697
+ # ═══════════════════════════════════════════════════════════════════════════════
698
+
699
+ class CascadeHoldSystem:
700
+ """Wrapper around cascade.Hold for the chess demo."""
701
+
702
+ def __init__(self):
703
+ if CASCADE_AVAILABLE:
704
+ self.hold = Hold()
705
+ self.causation_graph = CausationGraph()
706
+ self.tracer = Tracer(self.causation_graph)
707
+ self.metrics = MetricsEngine()
708
+ else:
709
+ self.hold = None
710
+ self.causation_graph = None
711
+ self.tracer = None
712
+ self.metrics = None
713
+
714
+ self.last_resolution = None
715
+
716
+ def yield_point(self, candidates: List[MoveCandidate], board: chess.Board) -> Optional[Any]:
717
+ """Create a hold yield point."""
718
+ if not self.hold or not candidates:
719
+ return None
720
+
721
+ try:
722
+ probs = np.array([c.prob for c in candidates], dtype=np.float32)
723
+ if probs.sum() > 0:
724
+ probs = probs / probs.sum()
725
+
726
+ resolution = self.hold.yield_point(
727
+ action_probs=probs,
728
+ value=candidates[0].value if candidates else 0.5,
729
+ observation={'fen': board.fen()},
730
+ brain_id='chess_3d',
731
+ action_labels=[c.move for c in candidates],
732
+ blocking=False
733
+ )
734
+ self.last_resolution = resolution
735
+ return resolution
736
+ except Exception as e:
737
+ st.error(f"Hold error: {e}")
738
+ return None
739
+
740
+ def accept(self):
741
+ """Accept AI's top choice."""
742
+ if self.hold:
743
+ return self.hold.accept()
744
+
745
+ def override(self, idx: int):
746
+ """Override with different choice."""
747
+ if self.hold:
748
+ return self.hold.override(idx)
749
+
750
+ def get_stats(self) -> dict:
751
+ """Get hold statistics."""
752
+ if self.hold:
753
+ return self.hold.stats
754
+ return {'total_holds': 0, 'overrides': 0, 'override_rate': 0.0}
755
+
756
+ # ═══════════════════════════════════════════════════════════════════════════════
757
+ # STOCKFISH INTEGRATION
758
+ # ═══════════════════════════════════════════════════════════════════════════════
759
+
760
+ def get_stockfish_candidates(board: chess.Board, engine, num_moves: int = 5) -> List[MoveCandidate]:
761
+ """Get candidate moves from Stockfish with evaluations."""
762
+ candidates = []
763
+
764
+ try:
765
+ # Get multi-PV analysis
766
+ info = engine.analyse(board, chess.engine.Limit(depth=12), multipv=num_moves)
767
+
768
+ total_score = 0
769
+ moves_data = []
770
+
771
+ for i, pv_info in enumerate(info):
772
+ move = pv_info['pv'][0]
773
+ score = pv_info.get('score', chess.engine.Cp(0))
774
+
775
+ # Convert score to value
776
+ if score.is_mate():
777
+ value = 1.0 if score.mate() > 0 else -1.0
778
+ else:
779
+ cp = score.relative.score(mate_score=10000)
780
+ value = max(-1, min(1, cp / 1000)) # Normalize to -1 to 1
781
+
782
+ # Probability decreases with rank
783
+ prob = 1.0 / (i + 1)
784
+ total_score += prob
785
+
786
+ moves_data.append({
787
+ 'move': move,
788
+ 'prob': prob,
789
+ 'value': value
790
+ })
791
+
792
+ # Normalize probabilities and build candidates with rich info
793
+ for data in moves_data:
794
+ data['prob'] /= total_score
795
+ move = data['move']
796
+
797
+ # Classify the move
798
+ is_capture = board.is_capture(move)
799
+ is_check = board.gives_check(move)
800
+ is_castle = board.is_castling(move)
801
+ captured_piece = None
802
+
803
+ if is_capture:
804
+ captured = board.piece_at(move.to_square)
805
+ if captured:
806
+ captured_piece = chess.piece_name(captured.piece_type)
807
+
808
+ # Determine move type
809
+ if is_check and is_capture:
810
+ move_type = 'check+capture'
811
+ elif is_check:
812
+ move_type = 'check'
813
+ elif is_capture:
814
+ move_type = 'capture'
815
+ elif is_castle:
816
+ move_type = 'castle'
817
+ elif move.promotion:
818
+ move_type = 'promotion'
819
+ else:
820
+ move_type = 'quiet'
821
+
822
+ candidates.append(MoveCandidate(
823
+ move=move.uci(),
824
+ prob=data['prob'],
825
+ value=data['value'],
826
+ from_sq=move.from_square,
827
+ to_sq=move.to_square,
828
+ is_capture=is_capture,
829
+ is_check=is_check,
830
+ is_castle=is_castle,
831
+ captured_piece=captured_piece,
832
+ move_type=move_type
833
+ ))
834
+
835
+ except Exception as e:
836
+ st.error(f"Stockfish error: {e}")
837
+
838
+ return candidates
839
+
840
+ # ═══════════════════════════════════════════════════════════════════════════════
841
+ # STREAMLIT APP
842
+ # ═══════════════════════════════════════════════════════════════════════════════
843
+
844
+ def init_session_state():
845
+ """Initialize session state."""
846
+ if 'board' not in st.session_state:
847
+ st.session_state.board = chess.Board()
848
+ if 'running' not in st.session_state:
849
+ st.session_state.running = False
850
+ if 'held' not in st.session_state:
851
+ st.session_state.held = False
852
+ if 'candidates' not in st.session_state:
853
+ st.session_state.candidates = []
854
+ if 'cascade' not in st.session_state:
855
+ st.session_state.cascade = CascadeHoldSystem()
856
+ if 'engine' not in st.session_state:
857
+ try:
858
+ import asyncio
859
+ # Fix for Python 3.13 on Windows - use ProactorEventLoop
860
+ if platform.system() == 'Windows':
861
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
862
+ st.session_state.engine = chess.engine.SimpleEngine.popen_uci(STOCKFISH_PATH)
863
+ print(f"[ENGINE] Stockfish loaded OK")
864
+ except Exception as e:
865
+ st.session_state.engine = None
866
+ print(f"[ENGINE] Failed to load Stockfish:")
867
+ import traceback
868
+ traceback.print_exc()
869
+ if 'move_history' not in st.session_state:
870
+ st.session_state.move_history = []
871
+
872
+ def main():
873
+ st.set_page_config(
874
+ page_title="CASCADE-LATTICE // 3D Chess",
875
+ layout="wide",
876
+ initial_sidebar_state="collapsed"
877
+ )
878
+
879
+ # Dark theme CSS
880
+ st.markdown("""
881
+ <style>
882
+ .stApp { background-color: #0a0a0f; }
883
+ .main-title {
884
+ font-family: 'Courier New', monospace;
885
+ font-size: 2.5em;
886
+ font-weight: bold;
887
+ color: #00ff96;
888
+ text-align: center;
889
+ margin-bottom: 0;
890
+ }
891
+ .sub-title {
892
+ font-family: 'Courier New', monospace;
893
+ font-size: 1em;
894
+ color: #666;
895
+ text-align: center;
896
+ margin-top: 0;
897
+ }
898
+ .control-btn {
899
+ font-family: 'Courier New', monospace;
900
+ font-size: 1.2em;
901
+ }
902
+ .stats-panel {
903
+ background: #12121a;
904
+ border: 1px solid #333;
905
+ border-radius: 8px;
906
+ padding: 15px;
907
+ margin: 10px 0;
908
+ }
909
+ .hold-active {
910
+ color: #ffd700;
911
+ font-weight: bold;
912
+ }
913
+ .running {
914
+ color: #00ff96;
915
+ }
916
+ .stopped {
917
+ color: #ff3264;
918
+ }
919
+ </style>
920
+ """, unsafe_allow_html=True)
921
+
922
+ init_session_state()
923
+
924
+ # Header
925
+ st.markdown('<div class="main-title">CASCADE // LATTICE</div>', unsafe_allow_html=True)
926
+ st.markdown('<div class="sub-title">3D Inference Visualization</div>', unsafe_allow_html=True)
927
+
928
+ # Control buttons
929
+ col1, col2, col3, col4, col5, col6 = st.columns([1, 1, 1, 1, 1, 2])
930
+
931
+ with col1:
932
+ if st.button("▶ AUTO", use_container_width=True, type="primary"):
933
+ st.session_state.running = True
934
+ st.session_state.held = False
935
+
936
+ with col2:
937
+ if st.button("⏹ STOP", use_container_width=True):
938
+ st.session_state.running = False
939
+ st.session_state.held = False
940
+
941
+ with col3:
942
+ # Manual step button
943
+ if st.button("⏭ STEP", use_container_width=True):
944
+ if st.session_state.engine and not st.session_state.board.is_game_over():
945
+ candidates = get_stockfish_candidates(st.session_state.board, st.session_state.engine)
946
+ if candidates:
947
+ move = chess.Move.from_uci(candidates[0].move)
948
+ st.session_state.board.push(move)
949
+ st.session_state.move_history.append(candidates[0].move)
950
+ st.rerun()
951
+
952
+ with col4:
953
+ if st.button("⏸ HOLD", use_container_width=True):
954
+ st.session_state.held = True
955
+ st.session_state.running = False
956
+ # Generate candidates when holding
957
+ if st.session_state.engine and not st.session_state.candidates:
958
+ st.session_state.candidates = get_stockfish_candidates(
959
+ st.session_state.board,
960
+ st.session_state.engine
961
+ )
962
+ # Create hold yield point
963
+ st.session_state.cascade.yield_point(
964
+ st.session_state.candidates,
965
+ st.session_state.board
966
+ )
967
+
968
+ with col5:
969
+ if st.button("🔄 RESET", use_container_width=True):
970
+ st.session_state.board = chess.Board()
971
+ st.session_state.candidates = []
972
+ st.session_state.move_history = []
973
+ st.session_state.running = False
974
+ st.session_state.held = False
975
+
976
+ with col6:
977
+ # Status display
978
+ if st.session_state.engine is None:
979
+ st.markdown('<span style="color:#ff4444">⚠ NO ENGINE</span>', unsafe_allow_html=True)
980
+ elif st.session_state.held:
981
+ st.markdown('<span class="hold-active">⏸ HOLD ACTIVE</span>', unsafe_allow_html=True)
982
+ elif st.session_state.running:
983
+ st.markdown('<span class="running">▶ RUNNING</span>', unsafe_allow_html=True)
984
+ else:
985
+ st.markdown('<span class="stopped">⏹ STOPPED</span>', unsafe_allow_html=True)
986
+
987
+ st.markdown("---")
988
+
989
+ # Main visualization
990
+ board = st.session_state.board
991
+ candidates = st.session_state.candidates if st.session_state.held else []
992
+
993
+ # Create and display 3D figure
994
+ fig = create_3d_chess_figure(board, candidates)
995
+ st.plotly_chart(fig, use_container_width=True, key="chess_3d")
996
+
997
+ # Move selection (when held)
998
+ if st.session_state.held and st.session_state.candidates:
999
+ st.markdown("### Select Move")
1000
+ cols = st.columns(len(st.session_state.candidates))
1001
+
1002
+ for i, (col, candidate) in enumerate(zip(cols, st.session_state.candidates)):
1003
+ with col:
1004
+ label = f"{candidate.move}\n{candidate.prob*100:.0f}%"
1005
+ if st.button(label, key=f"move_{i}", use_container_width=True):
1006
+ # Make the move
1007
+ move = chess.Move.from_uci(candidate.move)
1008
+ board.push(move)
1009
+ st.session_state.move_history.append(candidate.move)
1010
+
1011
+ # Resolve hold
1012
+ if i == 0:
1013
+ st.session_state.cascade.accept()
1014
+ else:
1015
+ st.session_state.cascade.override(i)
1016
+
1017
+ # Clear state
1018
+ st.session_state.candidates = []
1019
+ st.session_state.held = False
1020
+ st.rerun()
1021
+
1022
+ # Sidebar stats
1023
+ with st.sidebar:
1024
+ st.markdown("## CASCADE-LATTICE")
1025
+
1026
+ stats = st.session_state.cascade.get_stats()
1027
+ st.metric("Total Holds", stats['total_holds'])
1028
+ st.metric("Overrides", stats['overrides'])
1029
+ st.metric("Override Rate", f"{stats['override_rate']*100:.1f}%")
1030
+
1031
+ st.markdown("---")
1032
+ st.markdown("### Move History")
1033
+ for i, move in enumerate(st.session_state.move_history[-10:]):
1034
+ color = WHITE_PIECE if i % 2 == 0 else BLACK_PIECE
1035
+ st.markdown(f"<span style='color:{color}'>{i+1}. {move}</span>", unsafe_allow_html=True)
1036
+
1037
+ # Auto-play logic - triggers rerun at end (non-blocking render)
1038
+ should_auto_step = st.session_state.running and not st.session_state.held and not board.is_game_over()
1039
+
1040
+ if should_auto_step and st.session_state.engine:
1041
+ candidates = get_stockfish_candidates(board, st.session_state.engine)
1042
+ if candidates:
1043
+ move = chess.Move.from_uci(candidates[0].move)
1044
+ board.push(move)
1045
+ st.session_state.move_history.append(candidates[0].move)
1046
+
1047
+ # Game over check
1048
+ if board.is_game_over():
1049
+ result = board.result()
1050
+ if result == "1-0":
1051
+ st.success("⚪ WHITE WINS!")
1052
+ elif result == "0-1":
1053
+ st.success("⚫ BLACK WINS!")
1054
+ else:
1055
+ st.info("🤝 DRAW")
1056
+ st.session_state.running = False
1057
+ should_auto_step = False
1058
+
1059
+ # Rerun for auto-play AFTER all rendering is complete
1060
+ if should_auto_step:
1061
+ time.sleep(1.2) # Pause to see the move
1062
+ st.rerun()
1063
+
1064
+ if __name__ == "__main__":
1065
+ main()