Eval-Bar mit Stockfish-Streaming, LESS entfernt, Move-Lock, AGENTS-Regeln
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ __pycache__/
|
||||
lara-chess/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
stockfish.exe
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
- Nach jeder Änderung am Proxy (server.py) oder an den Requests (app.js) muss der Proxy neu gestartet werden: erst alten Prozess killen (`Get-Process -Name python | Stop-Process -Force`), dann unsichtbar starten.
|
||||
- Commits und Push erfolgen nur durch expliziten Befehl des Nutzers.
|
||||
- Der Server (server.py) wird immer unsichtbar gestartet: `Start-Process -NoNewWindow -FilePath "python" -ArgumentList "server.py"`
|
||||
- **LESS-Workflow**: CSS-Quelldatei ist `style.less`. Bei Serverstart wird automatisch `style.css` via `node build-less.js` kompiliert. Bei manuellen Änderungen an `style.less`: `node build-less.js` ausführen, dann Server neustarten.
|
||||
- `style.css` ist auto-generiert – keine manuellen Änderungen dort vornehmen.
|
||||
- Bei Updates (Polling/Code-Änderungen) niemals den aktuell angezeigten Zug wechseln. Der Benutzer bleibt auf dem von ihm ausgewählten Zug.
|
||||
|
||||
|
||||
15
index.html
15
index.html
@@ -5,9 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🏆 Lara Kiesewetter – Live Schachturnier</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/@chrisoakman/chessboardjs@1.0.0/dist/chessboard-1.0.0.min.css">
|
||||
<link rel="stylesheet/less" href="less/style.less">
|
||||
<script>less = { env: 'development' };</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/less@4.6.4/dist/less.min.js"></script>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@@ -32,7 +30,18 @@
|
||||
</div>
|
||||
<div class="player-clock" id="black-clock">--:--:--</div>
|
||||
</div>
|
||||
<div id="board-row">
|
||||
<div id="board"></div>
|
||||
<div id="eval-bar-container">
|
||||
<div id="eval-bar">
|
||||
<div id="eval-bar-fill"></div>
|
||||
<div id="eval-bar-marker"></div>
|
||||
</div>
|
||||
<div id="eval-info">
|
||||
<span id="eval-score">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-white" class="player-info">
|
||||
<div class="player-avatar">⬜</div>
|
||||
<div class="player-details">
|
||||
|
||||
163
js/app.js
163
js/app.js
@@ -11,10 +11,13 @@ let currentGame = null;
|
||||
let allLaraGames = [];
|
||||
let currentMoveIndex = -1;
|
||||
let userSelectedGame = false;
|
||||
let userScrolledMoves = false;
|
||||
let evalAbortController = null;
|
||||
let lastEvalFen = null;
|
||||
let currentRound = 0;
|
||||
let roundPgns = {};
|
||||
let pollId = 0;
|
||||
let pollInterval = null;
|
||||
let updateTimer = null;
|
||||
|
||||
async function fetchRoundPGN(round) {
|
||||
const res = await fetch(`https://www.deutsche-schachjugend.de/2026/odjm-d/partien/${round}.pgn?t=${Date.now()}`);
|
||||
@@ -54,11 +57,7 @@ async function loadPGN(showOverlay = true) {
|
||||
|
||||
if (!userSelectedGame) {
|
||||
const liveGame = getLiveGame(allLaraGames);
|
||||
const newGame = liveGame || getLatestGame(allLaraGames);
|
||||
if (newGame !== currentGame) {
|
||||
userScrolledMoves = false;
|
||||
}
|
||||
currentGame = newGame;
|
||||
currentGame = liveGame || getLatestGame(allLaraGames);
|
||||
}
|
||||
updateBoard();
|
||||
updatePlayerInfo();
|
||||
@@ -82,26 +81,20 @@ async function loadPGN(showOverlay = true) {
|
||||
function updateBoard() {
|
||||
if (!currentGame) return;
|
||||
|
||||
chess = new Chess();
|
||||
|
||||
// Spiegele das Brett, wenn Lara Schwarz hat
|
||||
const laraIsBlack = currentGame.black.toLowerCase().includes('kiesewetter');
|
||||
const orientation = laraIsBlack ? 'black' : 'white';
|
||||
|
||||
// Führe alle Züge aus
|
||||
const nonResultMoves = currentGame.moves.filter(m => !m.isResult);
|
||||
for (const move of nonResultMoves) {
|
||||
try {
|
||||
chess.move(move.san);
|
||||
} catch {
|
||||
// Ignoriere ungültige Züge
|
||||
}
|
||||
}
|
||||
if (!userScrolledMoves) {
|
||||
|
||||
// Aktuellen Move beibehalten — nie automatisch zum letzten Zug springen
|
||||
if (currentMoveIndex >= nonResultMoves.length || currentMoveIndex < -1) {
|
||||
currentMoveIndex = nonResultMoves.length - 1;
|
||||
} else if (currentMoveIndex >= 0) {
|
||||
// Benutzer hat navigiert – zeige Brett an seinem ausgewählten Zug
|
||||
}
|
||||
|
||||
chess = new Chess();
|
||||
if (currentMoveIndex >= 0) {
|
||||
for (let i = 0; i <= currentMoveIndex && i < nonResultMoves.length; i++) {
|
||||
try {
|
||||
chess.move(nonResultMoves[i].san);
|
||||
@@ -131,6 +124,105 @@ function updateBoard() {
|
||||
|
||||
// Highlight active player
|
||||
highlightActivePlayer();
|
||||
|
||||
syncEvalBarHeight();
|
||||
updateEvaluation();
|
||||
}
|
||||
|
||||
function syncEvalBarHeight() {
|
||||
const boardEl = document.getElementById('board');
|
||||
const evalContainer = document.getElementById('eval-bar-container');
|
||||
if (boardEl && evalContainer) {
|
||||
const h = boardEl.offsetHeight;
|
||||
if (h > 100) evalContainer.style.minHeight = h + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet FEN an Stockfish und aktualisiert die Eval-Bar
|
||||
*/
|
||||
async function updateEvaluation() {
|
||||
if (!chess) return;
|
||||
const fen = chess.fen();
|
||||
|
||||
if (lastEvalFen === fen && evalAbortController) return;
|
||||
lastEvalFen = fen;
|
||||
|
||||
if (evalAbortController) {
|
||||
evalAbortController.abort();
|
||||
}
|
||||
evalAbortController = new AbortController();
|
||||
|
||||
try {
|
||||
const res = await fetch('/evaluate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fen }),
|
||||
signal: evalAbortController.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error('Eval fehlgeschlagen');
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
displayEval(JSON.parse(line));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try { displayEval(JSON.parse(buffer)); } catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
const el = document.getElementById('eval-score');
|
||||
if (el) el.textContent = '?';
|
||||
}
|
||||
}
|
||||
|
||||
function displayEval(data) {
|
||||
const evalScore = document.getElementById('eval-score');
|
||||
const barFill = document.getElementById('eval-bar-fill');
|
||||
const barMarker = document.getElementById('eval-bar-marker');
|
||||
|
||||
let scoreText = '0.00';
|
||||
let fillPercent = 50;
|
||||
|
||||
if (data.scoreMate !== null && data.scoreMate !== undefined && data.scoreMate !== 0) {
|
||||
const mate = parseInt(data.scoreMate);
|
||||
const whiteMate = chess && chess.turn() === 'w' ? mate : -mate;
|
||||
if (whiteMate > 0) {
|
||||
scoreText = '#' + whiteMate;
|
||||
fillPercent = 100;
|
||||
} else {
|
||||
scoreText = '#' + Math.abs(whiteMate);
|
||||
fillPercent = 0;
|
||||
}
|
||||
} else if (data.scoreCp !== null && data.scoreCp !== undefined) {
|
||||
const cp = parseInt(data.scoreCp);
|
||||
const whiteCp = chess && chess.turn() === 'w' ? cp : -cp;
|
||||
const pawns = (whiteCp / 100).toFixed(2);
|
||||
scoreText = (whiteCp > 0 ? '+' : '') + pawns;
|
||||
fillPercent = 50 + whiteCp / 14;
|
||||
fillPercent = Math.max(0, Math.min(100, fillPercent));
|
||||
}
|
||||
|
||||
evalScore.textContent = scoreText;
|
||||
|
||||
if (barFill) barFill.style.height = fillPercent + '%';
|
||||
if (barMarker) barMarker.style.top = (100 - fillPercent) + '%';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +236,6 @@ function goToMove(index) {
|
||||
if (index < -1) index = -1;
|
||||
if (index >= nonResultMoves.length) index = nonResultMoves.length - 1;
|
||||
|
||||
userScrolledMoves = index < nonResultMoves.length - 1;
|
||||
currentMoveIndex = index;
|
||||
|
||||
chess = new Chess();
|
||||
@@ -160,6 +251,8 @@ function goToMove(index) {
|
||||
highlightActivePlayer();
|
||||
highlightLastMove();
|
||||
updateClocks(index);
|
||||
syncEvalBarHeight();
|
||||
updateEvaluation();
|
||||
|
||||
document.querySelectorAll('#moves-list .move').forEach(el => el.classList.remove('current'));
|
||||
if (index >= 0) {
|
||||
@@ -382,11 +475,6 @@ function updateMovesList() {
|
||||
const curMove = movesList.querySelector(`[data-index="${currentMoveIndex}"]`);
|
||||
if (curMove) curMove.classList.add('current');
|
||||
}
|
||||
|
||||
// Scroll to bottom nur wenn Benutzer nicht manuell navigiert hat
|
||||
if (!userScrolledMoves) {
|
||||
movesList.scrollTop = movesList.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,7 +514,6 @@ function updateAllGamesList() {
|
||||
|
||||
entry.addEventListener('click', () => {
|
||||
userSelectedGame = true;
|
||||
userScrolledMoves = false;
|
||||
currentGame = game;
|
||||
updateBoard();
|
||||
updatePlayerInfo();
|
||||
@@ -531,9 +618,11 @@ function updateTimestamp() {
|
||||
* Startet Auto-Refresh alle 30 Sekunden
|
||||
*/
|
||||
function startAutoRefresh() {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(updateTimer);
|
||||
|
||||
const myId = ++pollId;
|
||||
let lastUpdate = Date.now();
|
||||
let pollInterval, timer;
|
||||
|
||||
document.getElementById('refresh-timer').textContent = '0s';
|
||||
document.getElementById('refresh-timer').style.color = '#4ade80';
|
||||
@@ -541,13 +630,13 @@ function startAutoRefresh() {
|
||||
loadPGN(true);
|
||||
|
||||
pollInterval = setInterval(() => {
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(timer); return; }
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); return; }
|
||||
loadPGN(false);
|
||||
lastUpdate = Date.now();
|
||||
}, 30000);
|
||||
|
||||
timer = setInterval(() => {
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(timer); return; }
|
||||
updateTimer = setInterval(() => {
|
||||
if (pollId !== myId) { clearInterval(pollInterval); clearInterval(updateTimer); return; }
|
||||
const elapsed = Date.now() - lastUpdate;
|
||||
const remaining = Math.max(0, 30000 - elapsed);
|
||||
const s = Math.floor(remaining / 1000);
|
||||
@@ -626,8 +715,24 @@ if (window.ResizeObserver) {
|
||||
ro.observe(document.getElementById('board'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Page Visibility API – Timer pausieren wenn Tab unsichtbar
|
||||
*/
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
clearInterval(pollInterval);
|
||||
clearInterval(updateTimer);
|
||||
} else {
|
||||
startAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Init
|
||||
*/
|
||||
loadPGN();
|
||||
startAutoRefresh();
|
||||
|
||||
// Eval-Bar initialisieren
|
||||
chess = new Chess();
|
||||
updateEvaluation();
|
||||
|
||||
544
less/style.less
544
less/style.less
@@ -1,544 +0,0 @@
|
||||
// Farbvariablen
|
||||
@primary: #e94560;
|
||||
@primary-glow: rgba(233, 69, 96, 0.3);
|
||||
@bg-dark: rgba(0, 0, 0, 0.3);
|
||||
@bg-darker: rgba(0, 0, 0, 0.4);
|
||||
@text-muted: #aaa;
|
||||
@text-light: #e0e0e0;
|
||||
@text-white: #fff;
|
||||
@accent-green: #4ade80;
|
||||
@gold: #ffd700;
|
||||
@font-mono: 'Courier New', monospace;
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
color: @text-light;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
header {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 16px 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
border-bottom: 2px solid @primary;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: @text-white;
|
||||
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 0.85rem;
|
||||
color: @text-muted;
|
||||
}
|
||||
|
||||
#last-update, #refresh-timer {
|
||||
font-family: @font-mono;
|
||||
font-size: 0.9rem;
|
||||
color: @accent-green;
|
||||
background: @bg-darker;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(74, 222, 128, 0.2);
|
||||
}
|
||||
|
||||
#refresh-btn {
|
||||
background: @primary;
|
||||
border: none;
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: transform 0.3s, background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #ff6b6b;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
#main-content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Board Section */
|
||||
#board-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
padding: 12px 16px;
|
||||
background: @bg-dark;
|
||||
border-radius: 12px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
|
||||
&.active {
|
||||
border-color: @primary;
|
||||
box-shadow: 0 0 15px @primary-glow;
|
||||
}
|
||||
}
|
||||
|
||||
.player-avatar {
|
||||
font-size: 2rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.player-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: @text-white;
|
||||
}
|
||||
|
||||
.player-elo {
|
||||
font-size: 0.85rem;
|
||||
color: @text-muted;
|
||||
}
|
||||
|
||||
.player-clock {
|
||||
font-family: @font-mono;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
min-width: 100px;
|
||||
text-align: center;
|
||||
color: @accent-green;
|
||||
}
|
||||
|
||||
#board {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
|
||||
[data-square].last-move-highlight {
|
||||
box-shadow: inset 0 0 3px 3px rgba(255, 200, 0, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
#pgn-panel, #pgn-panel-mobile {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#pgn-panel-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pgn-panel--desktop,
|
||||
.pgn-panel--mobile {
|
||||
background: @bg-dark;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.pgn-panel--desktop {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.pgn-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
color: @primary;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.copy-pgn-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #ccc;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.pgn-text {
|
||||
font-family: @font-mono;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: @text-muted;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
background: @bg-darker;
|
||||
border-radius: 6px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
#info-section {
|
||||
flex: 0 0 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#game-info h2 {
|
||||
font-size: 1.3rem;
|
||||
color: @primary;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#result-info {
|
||||
font-size: 1.1rem;
|
||||
padding: 8px 12px;
|
||||
background: @bg-dark;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#moves-panel, #all-games-panel, #standings-panel, #pgn-panel-mobile {
|
||||
background: @bg-dark;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
|
||||
h3 {
|
||||
margin-bottom: 12px;
|
||||
color: @primary;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
#standings-content {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
|
||||
.standings-loading {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.standings-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.standings-label {
|
||||
color: @text-muted;
|
||||
}
|
||||
|
||||
.standings-value {
|
||||
color: @text-white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.standings-rank {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: @primary;
|
||||
text-align: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.standings-rank-label {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.standings-header {
|
||||
text-align: center;
|
||||
margin-bottom: 8px;
|
||||
color: @gold;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
#moves-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: @font-mono;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.8;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
|
||||
.move-number {
|
||||
color: #888;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.move {
|
||||
color: @text-light;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: @primary-glow;
|
||||
}
|
||||
|
||||
&.current {
|
||||
background: @primary;
|
||||
color: @text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.lara-move {
|
||||
color: @gold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.opp-move {
|
||||
color: @text-muted;
|
||||
}
|
||||
}
|
||||
|
||||
#all-games-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-entry {
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-left: 3px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-left-color: @primary;
|
||||
background: rgba(233, 69, 96, 0.15);
|
||||
}
|
||||
|
||||
.game-round {
|
||||
font-weight: bold;
|
||||
color: @primary;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.game-players {
|
||||
font-size: 0.9rem;
|
||||
color: #ccc;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.game-result {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overlays */
|
||||
#loading-overlay, #error-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
gap: 16px;
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
#error-overlay button {
|
||||
padding: 10px 24px;
|
||||
background: @primary;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid @primary-glow;
|
||||
border-top-color: @primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: @primary;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.round-info-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
#main-content {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#info-section {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#board-section {
|
||||
width: 100%;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#board,
|
||||
.player-info {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#board {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#board > div {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.round-info-mobile {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
font-size: 1.3rem;
|
||||
color: @primary;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#round-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#result-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pgn-panel--desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#pgn-panel-mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.player-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.player-clock {
|
||||
font-size: 1.1rem;
|
||||
min-width: 80px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
{
|
||||
"scripts": {
|
||||
"build:css": "node build-less.js"
|
||||
},
|
||||
"scripts": {},
|
||||
"devDependencies": {
|
||||
"@eslint/css": "^1.2.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@eslint/json": "^1.2.0",
|
||||
"@eslint/markdown": "^8.0.2",
|
||||
"eslint": "^10.4.0",
|
||||
"globals": "^17.6.0",
|
||||
"less": "^4.6.4"
|
||||
"globals": "^17.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
205
server.py
205
server.py
@@ -1,27 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Lokaler Server für Lara Schachturnier
|
||||
Serviert statische Dateien direkt.
|
||||
Serviert statische Dateien und bietet Stockfish-Analyse.
|
||||
"""
|
||||
|
||||
import http.server
|
||||
import socketserver
|
||||
import os
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import socket
|
||||
|
||||
PORT = int(os.environ.get("PORT", 8111))
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
STOCKFISH_PATH = os.environ.get("STOCKFISH_PATH")
|
||||
if not STOCKFISH_PATH:
|
||||
win_path = os.path.join(BASE_DIR, "stockfish.exe")
|
||||
if os.path.exists(win_path):
|
||||
STOCKFISH_PATH = win_path
|
||||
else:
|
||||
STOCKFISH_PATH = "stockfish"
|
||||
|
||||
STOCKFISH_DEPTH = int(os.environ.get("STOCKFISH_DEPTH", 15))
|
||||
|
||||
_stockfish_lock = threading.Lock()
|
||||
|
||||
|
||||
class StockfishEngine:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.proc = None
|
||||
|
||||
def start(self):
|
||||
if self.proc:
|
||||
return
|
||||
flags = 0
|
||||
if sys.platform == "win32":
|
||||
flags = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
self.proc = subprocess.Popen(
|
||||
[self.path],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
creationflags=flags,
|
||||
)
|
||||
self._send("uci")
|
||||
self._read_until("uciok")
|
||||
self._send("isready")
|
||||
self._read_until("readyok")
|
||||
print(f"[STOCKFISH] Engine gestartet: {self.path}")
|
||||
|
||||
def _send(self, cmd):
|
||||
self.proc.stdin.write(cmd + "\n")
|
||||
self.proc.stdin.flush()
|
||||
|
||||
def _read_until(self, marker):
|
||||
while True:
|
||||
line = self.proc.stdout.readline().strip()
|
||||
if line == marker:
|
||||
return
|
||||
|
||||
def evaluate(self, fen):
|
||||
self._send(f"position fen {fen}")
|
||||
self._send(f"go movetime 10000")
|
||||
|
||||
score_cp = None
|
||||
score_mate = None
|
||||
bestmove = None
|
||||
pv = None
|
||||
last_depth = 0
|
||||
|
||||
while True:
|
||||
line = self.proc.stdout.readline().strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if "score cp" in line:
|
||||
m = re.search(r"score cp (-?\d+)", line)
|
||||
if m:
|
||||
score_cp = int(m.group(1))
|
||||
if "score mate" in line:
|
||||
m = re.search(r"score mate (-?\d+)", line)
|
||||
if m:
|
||||
score_mate = int(m.group(1))
|
||||
if " pv " in line:
|
||||
pv = line.split(" pv ", 1)[1]
|
||||
|
||||
depth_m = re.search(r"depth (\d+)", line)
|
||||
if depth_m and score_cp is not None:
|
||||
new_depth = int(depth_m.group(1))
|
||||
if new_depth != last_depth:
|
||||
last_depth = new_depth
|
||||
yield {
|
||||
"scoreCp": score_cp,
|
||||
"scoreMate": score_mate,
|
||||
"bestMove": None,
|
||||
"pv": pv,
|
||||
"depth": new_depth,
|
||||
}
|
||||
|
||||
if line.startswith("bestmove"):
|
||||
parts = line.split()
|
||||
bestmove = parts[1] if len(parts) > 1 else None
|
||||
break
|
||||
|
||||
yield {
|
||||
"scoreCp": score_cp,
|
||||
"scoreMate": score_mate,
|
||||
"bestMove": bestmove,
|
||||
"pv": pv,
|
||||
}
|
||||
|
||||
def stop(self):
|
||||
if self.proc:
|
||||
self.proc.terminate()
|
||||
self.proc = None
|
||||
|
||||
|
||||
_engine = StockfishEngine(STOCKFISH_PATH)
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
class StaticHandler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/":
|
||||
self.path = "/index.html"
|
||||
|
||||
filepath = os.path.join(os.path.dirname(os.path.abspath(__file__)), self.path.lstrip("/"))
|
||||
filepath = os.path.join(BASE_DIR, self.path.lstrip("/"))
|
||||
|
||||
if os.path.isfile(filepath):
|
||||
content_types = {
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".less": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".png": "image/png",
|
||||
@@ -37,29 +154,101 @@ class StaticHandler(http.server.BaseHTTPRequestHandler):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/evaluate":
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(length) if length else b"{}"
|
||||
|
||||
try:
|
||||
data = json.loads(body)
|
||||
fen = data.get("fen", "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
self._send_json({"error": "Invalid JSON"}, 400)
|
||||
return
|
||||
|
||||
if not fen:
|
||||
self._send_json({"error": "Missing fen"}, 400)
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.flush()
|
||||
|
||||
# TCP_NODELAY für sofortiges Senden
|
||||
try:
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.close_connection = True
|
||||
try:
|
||||
with _stockfish_lock:
|
||||
try:
|
||||
_engine.start()
|
||||
except FileNotFoundError:
|
||||
self.wfile.write(
|
||||
json.dumps({"error": "Stockfish nicht gefunden"}).encode("utf-8") + b"\n"
|
||||
)
|
||||
self.wfile.flush()
|
||||
return
|
||||
|
||||
for result in _engine.evaluate(fen):
|
||||
data = json.dumps(result).encode("utf-8") + b"\n"
|
||||
self.wfile.write(data)
|
||||
self.wfile.flush()
|
||||
except Exception as e:
|
||||
print(f"[STOCKFISH] Fehler: {e}")
|
||||
self.wfile.write(
|
||||
json.dumps({"error": str(e)}).encode("utf-8") + b"\n"
|
||||
)
|
||||
self.wfile.flush()
|
||||
else:
|
||||
self._send_json({"error": "Not found"}, 404)
|
||||
|
||||
def _send_json(self, obj, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(obj).encode("utf-8"))
|
||||
|
||||
def log_message(self, format, *args):
|
||||
print(f"[{self.log_date_time_string()}] {args[0]}")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" [TROPHY] Lara Kiesewetter – Live Schachturnier")
|
||||
print(" [TROPHY] Lara Kiesewetter - Live Schachturnier")
|
||||
print("=" * 50)
|
||||
print(f" Server läuft auf: http://localhost:{PORT}")
|
||||
print(f" Drücke Ctrl+C zum Beenden")
|
||||
print(f" Server laeuft auf: http://localhost:{PORT}")
|
||||
if os.path.exists(STOCKFISH_PATH) or STOCKFISH_PATH == "stockfish":
|
||||
print(f" Stockfish-Analyse aktiv (depth={STOCKFISH_DEPTH})")
|
||||
else:
|
||||
print(f" Stockfish nicht gefunden unter: {STOCKFISH_PATH}")
|
||||
print(f" Druecke Ctrl+C zum Beenden")
|
||||
print("=" * 50)
|
||||
|
||||
with socketserver.TCPServer(("", PORT), StaticHandler) as httpd:
|
||||
socketserver.ThreadingTCPServer.allow_reuse_address = True
|
||||
with socketserver.ThreadingTCPServer(("", PORT), Handler) as httpd:
|
||||
print(f"\n[SERVER] Server gestartet: http://localhost:{PORT}\n")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[BYE] Server gestoppt.")
|
||||
_engine.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
60
style.css
60
style.css
@@ -133,6 +133,66 @@ header h1 {
|
||||
#board [data-square].last-move-highlight {
|
||||
box-shadow: inset 0 0 3px 3px rgba(255, 200, 0, 0.7);
|
||||
}
|
||||
/* Board Row: Board + Eval-Bar nebeneinander */
|
||||
#board-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
}
|
||||
#eval-bar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
min-height: 350px;
|
||||
}
|
||||
#eval-bar {
|
||||
flex: 1;
|
||||
width: 16px;
|
||||
background: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
#eval-bar-fill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: linear-gradient(to top, #f0f0f0 0%, #d0d0d0 100%);
|
||||
transition: height 0.3s ease;
|
||||
border-radius: 0 0 7px 7px;
|
||||
height: 50%;
|
||||
}
|
||||
#eval-bar-marker {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
right: -2px;
|
||||
height: 2px;
|
||||
background: #000;
|
||||
transition: top 0.3s ease;
|
||||
z-index: 2;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
|
||||
top: 50%;
|
||||
}
|
||||
#eval-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#eval-score {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#pgn-panel,
|
||||
#pgn-panel-mobile {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user