From 761d7ea3e8979f9cdca255a5ab1512a403a423f0 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Thu, 28 Aug 2025 00:20:06 -0700
Subject: [PATCH 1/2] feat: implement proper mate display and checkmate
detection for Stockfish analysis
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Display mate values as +M2/-M2 instead of +100/-100 to show whose mate it is
- Add independent checkmate detection that works without Stockfish analysis
- Fix checkmate positions showing "..." instead of "Checkmate"
- Set Maia win rate to 0%/100% for mate and checkmate positions
- Support both client-side Stockfish and backend API evaluation sources
- Maintain backward compatibility with normal centipawn evaluations
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
src/components/Analysis/Highlight.tsx | 67 +++++++++++++++++++++++++--
src/lib/analysis.ts | 27 ++++++++++-
src/lib/engine/stockfish.ts | 26 ++++++++++-
src/types/analysis.ts | 2 +
4 files changed, 114 insertions(+), 8 deletions(-)
diff --git a/src/components/Analysis/Highlight.tsx b/src/components/Analysis/Highlight.tsx
index 2edddae1..0751f69e 100644
--- a/src/components/Analysis/Highlight.tsx
+++ b/src/components/Analysis/Highlight.tsx
@@ -1,3 +1,4 @@
+import { Chess } from 'chess.ts'
import { cpToWinrate } from 'src/lib'
import { MoveTooltip } from './MoveTooltip'
import { InteractiveDescription } from './InteractiveDescription'
@@ -54,6 +55,35 @@ export const Highlight: React.FC = ({
isHomePage = false,
}: Props) => {
const { isMobile } = useContext(WindowSizeContext)
+
+ // Check if current position is checkmate (independent of Stockfish analysis)
+ const isCurrentPositionCheckmate = currentNode
+ ? (() => {
+ try {
+ const chess = new Chess(currentNode.fen)
+ return chess.inCheckmate()
+ } catch {
+ return false
+ }
+ })()
+ : false
+
+ // Helper function to format evaluation values
+ const formatEvaluation = (
+ cp: number,
+ mateIn?: number,
+ isCheckmate?: boolean,
+ ) => {
+ if (isCheckmate) {
+ return 'Checkmate'
+ }
+ if (mateIn !== undefined) {
+ // Show +M2/-M2 to indicate whose mate it is
+ const sign = mateIn > 0 ? '+' : '-'
+ return `${sign}M${Math.abs(mateIn)}`
+ }
+ return `${cp > 0 ? '+' : ''}${(cp / 100).toFixed(2)}`
+ }
const [tooltipData, setTooltipData] = useState<{
move: string
maiaProb?: number
@@ -210,6 +240,26 @@ export const Highlight: React.FC = ({
// Get the appropriate win rate
const getWhiteWinRate = () => {
+ // Handle checkmate positions (check this first, even without Stockfish analysis)
+ if (isCurrentPositionCheckmate) {
+ // If it's checkmate, the current player has lost
+ const currentTurn = currentNode?.turn || 'w'
+ return currentTurn === 'w' ? '0.0%' : '100.0%'
+ }
+
+ // Handle checkmate positions detected by Stockfish
+ if (moveEvaluation?.stockfish?.is_checkmate) {
+ // If it's checkmate, the current player has lost
+ const currentTurn = currentNode?.turn || 'w'
+ return currentTurn === 'w' ? '0.0%' : '100.0%'
+ }
+
+ // Handle mate in X positions
+ if (moveEvaluation?.stockfish?.mate_in !== undefined) {
+ const mateIn = moveEvaluation.stockfish.mate_in
+ return mateIn > 0 ? '100.0%' : '0.0%'
+ }
+
if (
isInFirst10Ply &&
moveEvaluation?.stockfish?.model_optimal_cp !== undefined
@@ -330,9 +380,15 @@ export const Highlight: React.FC = ({
: ''}
- {moveEvaluation?.stockfish
- ? `${moveEvaluation.stockfish.model_optimal_cp > 0 ? '+' : ''}${moveEvaluation.stockfish.model_optimal_cp / 100}`
- : '...'}
+ {isCurrentPositionCheckmate
+ ? 'Checkmate'
+ : moveEvaluation?.stockfish
+ ? formatEvaluation(
+ moveEvaluation.stockfish.model_optimal_cp,
+ moveEvaluation.stockfish.mate_in,
+ moveEvaluation.stockfish.is_checkmate,
+ )
+ : '...'}
@@ -386,8 +442,9 @@ export const Highlight: React.FC = ({
{colorSanMapping[move]?.san ?? move}
- {cp > 0 ? '+' : null}
- {`${(cp / 100).toFixed(2)}`}
+ {Math.abs(cp) >= 10000
+ ? `${cp > 0 ? '+' : '-'}M${Math.max(1, Math.floor(Math.abs(10000 - Math.abs(cp)) / 100) + 1)}`
+ : `${cp > 0 ? '+' : ''}${(cp / 100).toFixed(2)}`}
)
diff --git a/src/lib/analysis.ts b/src/lib/analysis.ts
index 208d9a5f..4d445469 100644
--- a/src/lib/analysis.ts
+++ b/src/lib/analysis.ts
@@ -17,13 +17,29 @@ export function convertBackendEvalToStockfishEval(
const cp_relative_vec: { [key: string]: number } = {}
let model_optimal_cp = -Infinity
let model_move = ''
+ let bestMateIn: number | undefined = undefined
+ // Detect mate values and convert them
for (const move in possibleMoves) {
const cp = possibleMoves[move]
+
+ // Detect mate patterns (±10000 indicates mate)
+ let mateIn: number | undefined = undefined
+ if (Math.abs(cp) >= 10000) {
+ // Estimate mate in moves based on how close to 10000 it is
+ // The closer to 10000, the faster the mate
+ const mateDistance = Math.abs(10000 - Math.abs(cp))
+ mateIn =
+ cp > 0
+ ? Math.max(1, Math.floor(mateDistance / 100) + 1)
+ : -Math.max(1, Math.floor(mateDistance / 100) + 1)
+ }
+
cp_vec[move] = cp
if (cp > model_optimal_cp) {
model_optimal_cp = cp
model_move = move
+ bestMateIn = mateIn
}
}
@@ -45,7 +61,9 @@ export function convertBackendEvalToStockfishEval(
for (const move in cp_vec_sorted) {
const cp = cp_vec_sorted[move]
- const winrate = cpToWinrate(cp, false)
+ // For mate positions, set winrate to 1.0 or 0.0
+ const isMate = Math.abs(cp) >= 10000
+ const winrate = isMate ? (cp > 0 ? 1.0 : 0.0) : cpToWinrate(cp, false)
winrate_vec[move] = winrate
if (winrate_vec[move] > max_winrate) {
@@ -71,8 +89,13 @@ export function convertBackendEvalToStockfishEval(
for (const move in cp_vec_sorted) {
cp_vec_sorted[move] *= -1
}
+ if (bestMateIn !== undefined) {
+ bestMateIn *= -1
+ }
}
+ // We can't easily detect checkmate from backend data without FEN,
+ // so we'll leave is_checkmate as undefined for backend evaluations
return {
sent: true,
depth: 20,
@@ -82,6 +105,8 @@ export function convertBackendEvalToStockfishEval(
cp_relative_vec: cp_relative_vec_sorted,
winrate_vec: winrate_vec_sorted,
winrate_loss_vec: winrate_loss_vec_sorted,
+ mate_in: bestMateIn,
+ is_checkmate: undefined,
}
}
diff --git a/src/lib/engine/stockfish.ts b/src/lib/engine/stockfish.ts
index 9d544652..f9158761 100644
--- a/src/lib/engine/stockfish.ts
+++ b/src/lib/engine/stockfish.ts
@@ -173,7 +173,11 @@ class Engine {
return
}
+ // Handle mate values properly instead of converting to ±10000
+ let mateIn: number | undefined = undefined
if (!isNaN(mate) && isNaN(cp)) {
+ // For mate scores, use a very high cp value for sorting but keep mate info
+ mateIn = mate
cp = mate > 0 ? 10000 : -10000
}
@@ -189,11 +193,16 @@ class Engine {
For example:
- If Stockfish reports CP = 100 (White's advantage) and it's White's turn, we keep CP = 100.
- If Stockfish reports CP = 100 (White's advantage) and it's Black's turn, we change CP to -100, indicating that Black is at a disadvantage.
+
+ The same logic applies to mate values - they need to be adjusted for the current player's perspective.
*/
const board = new Chess(this.fen)
const isBlackTurn = board.turn() === 'b'
if (isBlackTurn) {
cp *= -1
+ if (mateIn !== undefined) {
+ mateIn *= -1
+ }
}
if (this.store[depth]) {
@@ -215,7 +224,11 @@ class Engine {
? this.store[depth].model_optimal_cp - cp
: cp - this.store[depth].model_optimal_cp
- const winrate = cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
+ const winrate = mateIn
+ ? mateIn > 0
+ ? 1.0
+ : 0.0
+ : cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
if (!this.store[depth].winrate_vec) {
this.store[depth].winrate_vec = {}
@@ -229,7 +242,11 @@ class Engine {
winrateVec[move] = winrate
}
} else {
- const winrate = cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
+ const winrate = mateIn
+ ? mateIn > 0
+ ? 1.0
+ : 0.0
+ : cpToWinrate(cp * (isBlackTurn ? -1 : 1), false)
this.store[depth] = {
depth: depth,
@@ -239,6 +256,7 @@ class Engine {
cp_relative_vec: { [move]: 0 },
winrate_vec: { [move]: winrate },
winrate_loss_vec: { [move]: 0 },
+ mate_in: mateIn,
sent: false,
}
}
@@ -279,6 +297,10 @@ class Engine {
)
}
+ // Check if position is checkmate (no legal moves and king in check)
+ const board = new Chess(this.fen)
+ this.store[depth].is_checkmate = board.inCheckmate()
+
this.store[depth].sent = true
if (this.evaluationResolver) {
this.evaluationResolver(this.store[depth])
diff --git a/src/types/analysis.ts b/src/types/analysis.ts
index 14897832..cc40250e 100644
--- a/src/types/analysis.ts
+++ b/src/types/analysis.ts
@@ -29,6 +29,8 @@ export interface StockfishEvaluation {
cp_relative_vec: { [key: string]: number }
winrate_vec?: { [key: string]: number }
winrate_loss_vec?: { [key: string]: number }
+ mate_in?: number
+ is_checkmate?: boolean
}
export interface CachedEngineAnalysisEntry {
From 70790efa2895b497d58f6e673eee6289d8ed4b27 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Thu, 28 Aug 2025 00:22:13 -0700
Subject: [PATCH 2/2] style: fix code formatting in settings components
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Improve line breaks and indentation in MaiaModelSettings.tsx
- Fix text wrapping in SoundSettings.tsx
- Apply consistent code formatting per ESLint/Prettier rules
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
---
src/components/Settings/MaiaModelSettings.tsx | 43 ++++++++++++++-----
src/components/Settings/SoundSettings.tsx | 4 +-
2 files changed, 35 insertions(+), 12 deletions(-)
diff --git a/src/components/Settings/MaiaModelSettings.tsx b/src/components/Settings/MaiaModelSettings.tsx
index b2c92ff8..d717ad24 100644
--- a/src/components/Settings/MaiaModelSettings.tsx
+++ b/src/components/Settings/MaiaModelSettings.tsx
@@ -119,9 +119,12 @@ export const MaiaModelSettings: React.FC = () => {
return (
-
Maia Neural Network Model
+
+ Maia Neural Network Model
+
- Manage your locally stored Maia chess engine model. The model is downloaded once and stored in your browser for offline use.
+ Manage your locally stored Maia chess engine model. The model is
+ downloaded once and stored in your browser for offline use.
@@ -129,8 +132,11 @@ export const MaiaModelSettings: React.FC = () => {
{!storageInfo?.supported && (
- warning
- IndexedDB storage is not supported in your browser. Model management features are unavailable.
+
+ warning
+
+ IndexedDB storage is not supported in your browser. Model
+ management features are unavailable.
)}
@@ -140,19 +146,26 @@ export const MaiaModelSettings: React.FC = () => {
{/* Status section */}
-
+
{statusDisplay.icon}
Model Status
-
{statusDisplay.text}
+
+ {statusDisplay.text}
+
{status === 'downloading' && (
)}
@@ -161,7 +174,9 @@ export const MaiaModelSettings: React.FC = () => {
{/* Storage Information section */}
{storageInfo && (
-
Storage Information
+
+ Storage Information
+
{storageInfo.modelSize && (
@@ -196,7 +211,9 @@ export const MaiaModelSettings: React.FC = () => {
onClick={handleRedownloadModel}
className="flex items-center justify-center gap-2 rounded-md border border-red-500/30 bg-red-500/20 px-4 py-2 text-red-200 transition-all duration-200 hover:border-red-500/40 hover:bg-red-500/30"
>
-
download
+
+ download
+
Download Model
)}
@@ -208,7 +225,9 @@ export const MaiaModelSettings: React.FC = () => {
disabled={status !== 'ready'}
className="flex items-center justify-center gap-2 rounded-md border border-white/10 bg-white/5 px-4 py-2 text-white/90 backdrop-blur-sm transition-all duration-200 hover:border-white/20 hover:bg-white/10 disabled:opacity-50"
>
-
refresh
+
+ refresh
+
Re-download
@@ -217,7 +236,9 @@ export const MaiaModelSettings: React.FC = () => {
disabled={isDeleting || status !== 'ready'}
className="flex items-center justify-center gap-2 rounded-md border border-red-500/30 bg-red-500/20 px-4 py-2 text-red-200 transition-all duration-200 hover:border-red-500/40 hover:bg-red-500/30 disabled:opacity-50"
>
-
delete
+
+ delete
+
{isDeleting ? 'Deleting...' : 'Delete Model'}
>
diff --git a/src/components/Settings/SoundSettings.tsx b/src/components/Settings/SoundSettings.tsx
index 30f902a3..f1cab354 100644
--- a/src/components/Settings/SoundSettings.tsx
+++ b/src/components/Settings/SoundSettings.tsx
@@ -33,7 +33,9 @@ export const SoundSettings: React.FC = () => {
{/* Sound Toggle */}
-
Enable Move Sounds
+
+ Enable Move Sounds
+
Play sounds when chess pieces are moved or captured