Spaces:
Running
Running
Centered board layout, replay overlay, mobile responsive CSS
Browse files- src/app_clean.py +722 -0
- src/app_dash.py +1540 -0
- src/app_threejs.py +0 -0
- src/assets/chess3d.html +461 -19
- src/streamlit_app_3d.py +1065 -0
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 |
-
//
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 ?
|
| 246 |
transparent: true,
|
| 247 |
-
opacity: 0.
|
| 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
|
| 284 |
let color;
|
| 285 |
if (isSelected) {
|
| 286 |
-
color =
|
| 287 |
} else if (isHuman) {
|
| 288 |
-
color =
|
| 289 |
} else if (isBlack) {
|
| 290 |
-
color = isCapture ?
|
| 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()
|