Skip to content

Commit 59e4959

Browse files
Merge pull request #251 from CSSLab/codex/material-counts-all-platforms
feat: standardize chess material counts
2 parents fa05420 + 9e2b61f commit 59e4959

8 files changed

Lines changed: 330 additions & 215 deletions

File tree

src/components/Analysis/BroadcastAnalysis.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { DrawShape } from 'chessground/draw'
1313
import { TABLET_BREAKPOINT_PX, WindowSizeContext } from 'src/contexts'
1414
import { MAIA_MODELS } from 'src/constants/common'
1515
import { GameInfo } from 'src/components/Common/GameInfo'
16+
import { MaterialBalance } from 'src/components/Common/MaterialBalance'
1617
import { GameBoard } from 'src/components/Board/GameBoard'
1718
import { PlayerInfo } from 'src/components/Common/PlayerInfo'
1819
import { MovesContainer } from 'src/components/Board/MovesContainer'
@@ -214,6 +215,12 @@ export const BroadcastAnalysis: React.FC<Props> = ({
214215
{game.whitePlayer.rating && (
215216
<span className="text-primary/60">({game.whitePlayer.rating})</span>
216217
)}
218+
<MaterialBalance
219+
fen={analysisController.currentNode?.fen}
220+
color="white"
221+
iconClassName="!text-xs text-primary/70"
222+
textClassName="text-primary/70"
223+
/>
217224
</div>
218225
<div className="flex items-center gap-1">
219226
{broadcastController.broadcastState.isLive && !game.termination ? (
@@ -238,6 +245,12 @@ export const BroadcastAnalysis: React.FC<Props> = ({
238245
{game.blackPlayer.rating && (
239246
<span className="text-primary/60">({game.blackPlayer.rating})</span>
240247
)}
248+
<MaterialBalance
249+
fen={analysisController.currentNode?.fen}
250+
color="black"
251+
iconClassName="!text-xs text-primary/70"
252+
textClassName="text-primary/70"
253+
/>
241254
</div>
242255
</div>
243256
</div>

src/components/Analysis/StreamAnalysis.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { DrawShape } from 'chessground/draw'
1313
import { TABLET_BREAKPOINT_PX, WindowSizeContext } from 'src/contexts'
1414
import { MAIA_MODELS } from 'src/constants/common'
1515
import { GameInfo } from 'src/components/Common/GameInfo'
16+
import { MaterialBalance } from 'src/components/Common/MaterialBalance'
1617
import { GameBoard } from 'src/components/Board/GameBoard'
1718
import { PlayerInfo } from 'src/components/Common/PlayerInfo'
1819
import { MovesContainer } from 'src/components/Board/MovesContainer'
@@ -225,6 +226,12 @@ export const StreamAnalysis: React.FC<Props> = ({
225226
{game.whitePlayer.rating && (
226227
<span className="text-primary/60">({game.whitePlayer.rating})</span>
227228
)}
229+
<MaterialBalance
230+
fen={analysisController.currentNode?.fen}
231+
color="white"
232+
iconClassName="!text-xs text-primary/70"
233+
textClassName="text-primary/70"
234+
/>
228235
</div>
229236
<div className="flex items-center gap-1">
230237
{streamState.isLive ? (
@@ -249,6 +256,12 @@ export const StreamAnalysis: React.FC<Props> = ({
249256
{game.blackPlayer.rating && (
250257
<span className="text-primary/60">({game.blackPlayer.rating})</span>
251258
)}
259+
<MaterialBalance
260+
fen={analysisController.currentNode?.fen}
261+
color="black"
262+
iconClassName="!text-xs text-primary/70"
263+
textClassName="text-primary/70"
264+
/>
252265
</div>
253266
</div>
254267
</div>

src/components/Board/GameClock.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useState, useEffect, useContext } from 'react'
33
import { Color } from 'src/types'
44
import { AuthContext } from 'src/contexts'
55
import { PlayControllerContext } from 'src/contexts/PlayControllerContext'
6+
import { MaterialBalance } from 'src/components/Common/MaterialBalance'
67

78
interface Props {
89
player: Color
@@ -13,8 +14,15 @@ export const GameClock: React.FC<Props> = (
1314
props: React.PropsWithChildren<Props>,
1415
) => {
1516
const { user } = useContext(AuthContext)
16-
const { player, toPlay, whiteClock, blackClock, lastMoveTime, maiaVersion } =
17-
useContext(PlayControllerContext)
17+
const {
18+
player,
19+
toPlay,
20+
whiteClock,
21+
blackClock,
22+
lastMoveTime,
23+
maiaVersion,
24+
currentNode,
25+
} = useContext(PlayControllerContext)
1826

1927
const [referenceTime, setReferenceTime] = useState<number>(Date.now())
2028

@@ -54,11 +62,20 @@ export const GameClock: React.FC<Props> = (
5462
<div
5563
className={`flex items-center justify-between bg-glass-strong md:items-start md:justify-start ${active ? 'opacity-100' : 'opacity-50'} flex-row md:flex-col`}
5664
>
57-
<div className="px-4 py-2">
58-
{props.player === 'black' ? '●' : '○'}{' '}
59-
{player === props.player
60-
? user?.displayName
61-
: getMaiaDisplayName(maiaVersion)}
65+
<div className="flex w-full items-center justify-between gap-3 px-4 py-2">
66+
<span>
67+
{props.player === 'black' ? '●' : '○'}{' '}
68+
{player === props.player
69+
? user?.displayName
70+
: getMaiaDisplayName(maiaVersion)}
71+
</span>
72+
<MaterialBalance
73+
fen={currentNode?.fen}
74+
color={props.player}
75+
className="gap-1.5"
76+
iconClassName="!text-base md:!text-lg text-white/85"
77+
textClassName="text-sm md:text-base text-white/85"
78+
/>
6279
</div>
6380
<div className="inline-flex self-start px-4 py-2 md:text-3xl">
6481
{minutes}:{('00' + seconds).slice(-2)}
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { useMemo } from 'react'
2+
import type { Color } from 'src/types'
3+
import { Chess } from 'chess.ts'
4+
5+
type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k'
6+
type MaterialCount = Record<PieceType, number>
7+
8+
const PIECE_DISPLAY_ORDER: PieceType[] = ['p', 'n', 'b', 'r', 'q']
9+
10+
const PIECE_VALUES: Record<PieceType, number> = {
11+
p: 1,
12+
n: 3,
13+
b: 3,
14+
r: 5,
15+
q: 9,
16+
k: 0,
17+
}
18+
19+
const STARTING_MATERIAL: { white: MaterialCount; black: MaterialCount } = {
20+
white: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 },
21+
black: { p: 8, n: 2, b: 2, r: 2, q: 1, k: 1 },
22+
}
23+
24+
const getPieceIcon = (piece: PieceType): string => {
25+
const iconMap: Record<PieceType, string> = {
26+
p: 'chess_pawn',
27+
n: 'chess_knight',
28+
b: 'chess_bishop',
29+
r: 'chess_rook',
30+
q: 'chess',
31+
k: 'chess',
32+
}
33+
34+
return iconMap[piece]
35+
}
36+
37+
const getPieceLabel = (piece: PieceType): string => {
38+
const labelMap: Record<PieceType, string> = {
39+
p: 'pawn',
40+
n: 'knight',
41+
b: 'bishop',
42+
r: 'rook',
43+
q: 'queen',
44+
k: 'king',
45+
}
46+
47+
return labelMap[piece]
48+
}
49+
50+
const calculateCapturedPieces = (fen: string) => {
51+
const chess = new Chess(fen)
52+
const board = chess.board()
53+
54+
const currentMaterial: { white: MaterialCount; black: MaterialCount } = {
55+
white: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 },
56+
black: { p: 0, n: 0, b: 0, r: 0, q: 0, k: 0 },
57+
}
58+
59+
for (const row of board) {
60+
for (const square of row) {
61+
if (!square) continue
62+
63+
const piece = square.type.toLowerCase() as PieceType
64+
const color = square.color === 'w' ? 'white' : 'black'
65+
currentMaterial[color][piece]++
66+
}
67+
}
68+
69+
const captured = {
70+
white: {} as Partial<Record<PieceType, number>>,
71+
black: {} as Partial<Record<PieceType, number>>,
72+
}
73+
74+
for (const piece of Object.keys(STARTING_MATERIAL.white) as PieceType[]) {
75+
const whiteCaptured =
76+
STARTING_MATERIAL.white[piece] - currentMaterial.white[piece]
77+
const blackCaptured =
78+
STARTING_MATERIAL.black[piece] - currentMaterial.black[piece]
79+
80+
if (whiteCaptured > 0) captured.white[piece] = whiteCaptured
81+
if (blackCaptured > 0) captured.black[piece] = blackCaptured
82+
}
83+
84+
return captured
85+
}
86+
87+
const calculateMaterialAdvantage = (fen: string) => {
88+
const chess = new Chess(fen)
89+
const board = chess.board()
90+
91+
let whiteTotal = 0
92+
let blackTotal = 0
93+
94+
for (const row of board) {
95+
for (const square of row) {
96+
if (!square) continue
97+
98+
const piece = square.type.toLowerCase() as PieceType
99+
if (square.color === 'w') {
100+
whiteTotal += PIECE_VALUES[piece]
101+
} else {
102+
blackTotal += PIECE_VALUES[piece]
103+
}
104+
}
105+
}
106+
107+
return { white: whiteTotal, black: blackTotal }
108+
}
109+
110+
export const MaterialBalance = ({
111+
fen,
112+
color,
113+
className = '',
114+
iconClassName = '',
115+
textClassName = '',
116+
}: {
117+
fen?: string
118+
color: Color
119+
className?: string
120+
iconClassName?: string
121+
textClassName?: string
122+
}) => {
123+
const materialData = useMemo(() => {
124+
if (!fen) {
125+
return null
126+
}
127+
128+
const capturedPieces = calculateCapturedPieces(fen)
129+
const materialAdvantage = calculateMaterialAdvantage(fen)
130+
const myCapturedPieces =
131+
color === 'white' ? capturedPieces.black : capturedPieces.white
132+
const opponentCapturedPieces =
133+
color === 'white' ? capturedPieces.white : capturedPieces.black
134+
const pieceDifference: Partial<Record<PieceType, number>> = {}
135+
136+
PIECE_DISPLAY_ORDER.forEach((piece) => {
137+
const myCount = myCapturedPieces[piece] ?? 0
138+
const opponentCount = opponentCapturedPieces[piece] ?? 0
139+
const net = myCount - opponentCount
140+
141+
if (net > 0) {
142+
pieceDifference[piece] = net
143+
}
144+
})
145+
146+
const netAdvantage = materialAdvantage.white - materialAdvantage.black
147+
const advantage =
148+
netAdvantage === 0
149+
? 0
150+
: netAdvantage > 0
151+
? color === 'white'
152+
? netAdvantage
153+
: 0
154+
: color === 'black'
155+
? Math.abs(netAdvantage)
156+
: 0
157+
158+
return { pieceDifference, advantage }
159+
}, [color, fen])
160+
161+
if (
162+
!materialData ||
163+
(Object.keys(materialData.pieceDifference).length === 0 &&
164+
materialData.advantage === 0)
165+
) {
166+
return null
167+
}
168+
169+
const capturedPieceToneClass =
170+
color === 'white'
171+
? 'text-zinc-900 [text-shadow:0_0_1px_rgba(255,255,255,0.95)]'
172+
: 'text-white [text-shadow:0_0_1px_rgba(0,0,0,0.9)]'
173+
174+
return (
175+
<div className={`flex select-none items-center gap-1 ${className}`.trim()}>
176+
<div className="flex items-center">
177+
{PIECE_DISPLAY_ORDER.map((piece) => {
178+
const count = materialData.pieceDifference[piece] ?? 0
179+
180+
if (count === 0) {
181+
return null
182+
}
183+
184+
return (
185+
<div key={piece} className="flex items-center gap-0">
186+
{Array.from({ length: count }).map((_, index) => (
187+
<span
188+
key={`${piece}-${index}`}
189+
className={`material-symbols-outlined material-symbols-filled text-sm ${capturedPieceToneClass} ${index > 0 ? '-ml-1.5' : ''} ${iconClassName}`.trim()}
190+
title={getPieceLabel(piece)}
191+
>
192+
{getPieceIcon(piece)}
193+
</span>
194+
))}
195+
</div>
196+
)
197+
})}
198+
</div>
199+
{materialData.advantage > 0 && (
200+
<span
201+
className={`text-xxs font-medium text-secondary ${textClassName}`.trim()}
202+
>
203+
+{materialData.advantage}
204+
</span>
205+
)}
206+
</div>
207+
)
208+
}

0 commit comments

Comments
 (0)