Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 62 additions & 5 deletions src/components/Analysis/Highlight.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Chess } from 'chess.ts'
import { cpToWinrate } from 'src/lib'
import { MoveTooltip } from './MoveTooltip'
import { InteractiveDescription } from './InteractiveDescription'
Expand Down Expand Up @@ -54,6 +55,35 @@ export const Highlight: React.FC<Props> = ({
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
Expand Down Expand Up @@ -210,6 +240,26 @@ export const Highlight: React.FC<Props> = ({

// 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
Expand Down Expand Up @@ -330,9 +380,15 @@ export const Highlight: React.FC<Props> = ({
: ''}
</p>
<p className="text-base font-bold text-engine-1 md:text-sm lg:text-lg">
{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,
)
: '...'}
</p>
</div>

Expand Down Expand Up @@ -386,8 +442,9 @@ export const Highlight: React.FC<Props> = ({
{colorSanMapping[move]?.san ?? move}
</p>
<p className="text-right font-mono text-sm md:text-xxs xl:text-xs">
{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)}`}
</p>
</button>
)
Expand Down
43 changes: 32 additions & 11 deletions src/components/Settings/MaiaModelSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,24 @@ export const MaiaModelSettings: React.FC = () => {
return (
<div className="from-white/8 to-white/4 group flex flex-col gap-4 rounded-lg border border-white/10 bg-gradient-to-br px-5 pb-0 pt-5 backdrop-blur-md transition-all duration-300">
<div className="flex flex-col items-start justify-between">
<h3 className="text-lg font-semibold text-white/95">Maia Neural Network Model</h3>
<h3 className="text-lg font-semibold text-white/95">
Maia Neural Network Model
</h3>
<p className="text-sm text-white/70">
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.
</p>
</div>
<div className="flex flex-col gap-2">
{/* Warning displayed above sections so bottom aligns with card */}
{!storageInfo?.supported && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-yellow-200">
<p className="text-sm">
<span className="material-symbols-outlined mr-2 inline text-base align-middle text-yellow-200">warning</span>
IndexedDB storage is not supported in your browser. Model management features are unavailable.
<span className="material-symbols-outlined mr-2 inline align-middle text-base text-yellow-200">
warning
</span>
IndexedDB storage is not supported in your browser. Model
management features are unavailable.
</p>
</div>
)}
Expand All @@ -140,19 +146,26 @@ export const MaiaModelSettings: React.FC = () => {
{/* Status section */}
<div className="px-5 py-4 text-white/90">
<div className="flex items-center gap-3">
<span className={`material-symbols-outlined text-xl ${statusDisplay.color}`}>
<span
className={`material-symbols-outlined text-xl ${statusDisplay.color}`}
>
{statusDisplay.icon}
</span>
<div className="flex flex-col">
<p className="font-medium text-white">Model Status</p>
<p className={`text-sm ${statusDisplay.color}`}>{statusDisplay.text}</p>
<p className={`text-sm ${statusDisplay.color}`}>
{statusDisplay.text}
</p>
</div>
</div>

{status === 'downloading' && (
<div className="mt-3">
<div className="h-2 w-full rounded-full bg-white/10">
<div className="h-2 rounded-full bg-red-500/70 transition-all duration-300" style={{ width: `${progress}%` }} />
<div
className="h-2 rounded-full bg-red-500/70 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
Expand All @@ -161,7 +174,9 @@ export const MaiaModelSettings: React.FC = () => {
{/* Storage Information section */}
{storageInfo && (
<div className="border-t border-white/10 px-5 py-4 text-white/90">
<h4 className="mb-3 font-medium text-white">Storage Information</h4>
<h4 className="mb-3 font-medium text-white">
Storage Information
</h4>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
{storageInfo.modelSize && (
<div className="flex justify-between">
Expand Down Expand Up @@ -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"
>
<span className="material-symbols-outlined text-base text-red-200">download</span>
<span className="material-symbols-outlined text-base text-red-200">
download
</span>
Download Model
</button>
)}
Expand All @@ -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"
>
<span className="material-symbols-outlined text-base text-white/80">refresh</span>
<span className="material-symbols-outlined text-base text-white/80">
refresh
</span>
Re-download
</button>

Expand All @@ -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"
>
<span className="material-symbols-outlined text-base text-red-200">delete</span>
<span className="material-symbols-outlined text-base text-red-200">
delete
</span>
{isDeleting ? 'Deleting...' : 'Delete Model'}
</button>
</>
Expand Down
4 changes: 3 additions & 1 deletion src/components/Settings/SoundSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export const SoundSettings: React.FC = () => {
{/* Sound Toggle */}
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="text-sm font-medium text-white/90">Enable Move Sounds</span>
<span className="text-sm font-medium text-white/90">
Enable Move Sounds
</span>
<p className="text-xs text-white/70">
Play sounds when chess pieces are moved or captured
</p>
Expand Down
27 changes: 26 additions & 1 deletion src/lib/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
}
}

Expand Down
26 changes: 24 additions & 2 deletions src/lib/engine/stockfish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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]) {
Expand All @@ -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 = {}
Expand All @@ -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,
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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])
Expand Down
2 changes: 2 additions & 0 deletions src/types/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading