Skip to content

Commit 9a35096

Browse files
Merge pull request #189 from CSSLab/feature/stream
Add live stream page for live analysis of ongoing Lichess games
2 parents fd716d1 + b45be68 commit 9a35096

File tree

20 files changed

+1936
-18
lines changed

20 files changed

+1936
-18
lines changed

__tests__/components/PlayerInfo.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('PlayerInfo Component', () => {
1616
it('should render player rating when provided', () => {
1717
render(<PlayerInfo {...defaultProps} rating={1500} />)
1818

19-
expect(screen.getByText('TestPlayer (1500)')).toBeInTheDocument()
19+
expect(screen.getByText('(1500)')).toBeInTheDocument()
2020
})
2121

2222
it('should not render rating when not provided', () => {

src/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './turing'
66
export * from './play'
77
export * from './profile'
88
export * from './opening'
9+
export * from './lichess'
910
export { getActiveUserCount } from './home'

src/api/lichess/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './streaming'

src/api/lichess/streaming.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { Chess } from 'chess.ts'
3+
import { GameTree } from 'src/types/base/tree'
4+
import { AvailableMoves } from 'src/types/training'
5+
import {
6+
LiveGame,
7+
Player,
8+
StockfishEvaluation,
9+
StreamedGame,
10+
StreamedMove,
11+
} from 'src/types'
12+
13+
const readStream = (processLine: (data: any) => void) => (response: any) => {
14+
const stream = response.body.getReader()
15+
const matcher = /\r?\n/
16+
const decoder = new TextDecoder()
17+
let buf = ''
18+
19+
const loop = () =>
20+
stream.read().then(({ done, value }: { done: boolean; value: any }) => {
21+
if (done) {
22+
if (buf.length > 0) processLine(JSON.parse(buf))
23+
} else {
24+
const chunk = decoder.decode(value, {
25+
stream: true,
26+
})
27+
buf += chunk
28+
29+
const parts = (buf || '').split(matcher)
30+
buf = parts.pop() as string
31+
for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
32+
33+
return loop()
34+
}
35+
})
36+
37+
return loop()
38+
}
39+
40+
export const getLichessTVGame = async () => {
41+
const res = await fetch('https://lichess.org/api/tv/channels')
42+
if (!res.ok) {
43+
throw new Error('Failed to fetch Lichess TV data')
44+
}
45+
const data = await res.json()
46+
47+
// Return the best rapid game (highest rated players)
48+
const bestChannel = data.rapid
49+
if (!bestChannel?.gameId) {
50+
throw new Error('No TV game available')
51+
}
52+
53+
return {
54+
gameId: bestChannel.gameId,
55+
white: bestChannel.user1,
56+
black: bestChannel.user2,
57+
}
58+
}
59+
60+
export const getLichessGameInfo = async (gameId: string) => {
61+
const res = await fetch(`https://lichess.org/api/game/${gameId}`)
62+
if (!res.ok) {
63+
throw new Error(`Failed to fetch game info for ${gameId}`)
64+
}
65+
return res.json()
66+
}
67+
68+
export const streamLichessGame = async (
69+
gameId: string,
70+
onGameInfo: (data: StreamedGame) => void,
71+
onMove: (data: StreamedMove) => void,
72+
onComplete: () => void,
73+
abortSignal?: AbortSignal,
74+
) => {
75+
const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, {
76+
signal: abortSignal,
77+
headers: {
78+
Accept: 'application/x-ndjson',
79+
},
80+
})
81+
82+
const onMessage = (message: any) => {
83+
if (message.id) {
84+
onGameInfo(message as StreamedGame)
85+
} else if (message.uci || message.lm) {
86+
onMove({
87+
fen: message.fen,
88+
uci: message.uci || message.lm,
89+
wc: message.wc,
90+
bc: message.bc,
91+
})
92+
} else {
93+
console.log('Unknown message format:', message)
94+
}
95+
}
96+
97+
try {
98+
const response = await stream
99+
100+
if (!response.ok) {
101+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
102+
}
103+
104+
if (!response.body) {
105+
throw new Error('No response body')
106+
}
107+
108+
await readStream(onMessage)(response).then(onComplete)
109+
} catch (error) {
110+
if (abortSignal?.aborted) {
111+
console.log('Stream aborted')
112+
} else {
113+
console.error('Stream error:', error)
114+
throw error
115+
}
116+
}
117+
}
118+
119+
export const createAnalyzedGameFromLichessStream = (
120+
gameData: any,
121+
): LiveGame => {
122+
const { players, id } = gameData
123+
124+
const whitePlayer: Player = {
125+
name: players?.white?.user?.id || 'White',
126+
rating: players?.white?.rating,
127+
}
128+
129+
const blackPlayer: Player = {
130+
name: players?.black?.user?.id || 'Black',
131+
rating: players?.black?.rating,
132+
}
133+
134+
const startingFen =
135+
gameData.initialFen ||
136+
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
137+
138+
const gameStates = [
139+
{
140+
board: startingFen,
141+
lastMove: undefined as [string, string] | undefined,
142+
san: undefined as string | undefined,
143+
check: false as const,
144+
maia_values: {},
145+
},
146+
]
147+
148+
const tree = new GameTree(startingFen)
149+
150+
return {
151+
id,
152+
blackPlayer,
153+
whitePlayer,
154+
gameType: 'stream',
155+
type: 'stream' as const,
156+
moves: gameStates,
157+
availableMoves: new Array(gameStates.length).fill({}) as AvailableMoves[],
158+
termination: undefined,
159+
maiaEvaluations: [],
160+
stockfishEvaluations: [],
161+
loadedFen: gameData.fen,
162+
loaded: false,
163+
tree,
164+
} as LiveGame
165+
}
166+
167+
export const parseLichessStreamMove = (
168+
moveData: StreamedMove,
169+
currentGame: LiveGame,
170+
) => {
171+
const { uci, fen } = moveData
172+
173+
if (!uci || !fen || !currentGame.tree) {
174+
return currentGame
175+
}
176+
177+
// Convert UCI to SAN notation using chess.js
178+
let san = uci // Fallback to UCI
179+
try {
180+
// Get the position before this move by finding the last node in the tree
181+
let beforeMoveNode = currentGame.tree.getRoot()
182+
while (beforeMoveNode.mainChild) {
183+
beforeMoveNode = beforeMoveNode.mainChild
184+
}
185+
186+
const chess = new Chess(beforeMoveNode.fen)
187+
const move = chess.move({
188+
from: uci.slice(0, 2),
189+
to: uci.slice(2, 4),
190+
promotion: uci[4] ? (uci[4] as any) : undefined,
191+
})
192+
193+
if (move) {
194+
san = move.san
195+
}
196+
} catch (error) {
197+
console.warn('Could not convert UCI to SAN:', error)
198+
// Keep UCI as fallback
199+
}
200+
201+
// Create new move object
202+
const newMove = {
203+
board: fen,
204+
lastMove: [uci.slice(0, 2), uci.slice(2, 4)] as [string, string],
205+
san: san,
206+
check: false as const, // We'd need to calculate this from the FEN
207+
maia_values: {},
208+
}
209+
210+
// Add to moves array
211+
const updatedMoves = [...currentGame.moves, newMove]
212+
213+
// Add to tree mainline - find the last node in the main line
214+
let currentNode = currentGame.tree.getRoot()
215+
while (currentNode.mainChild) {
216+
currentNode = currentNode.mainChild
217+
}
218+
219+
try {
220+
currentGame.tree.addMainMove(currentNode, fen, uci, san)
221+
} catch (error) {
222+
console.error('Error adding move to tree:', error)
223+
// Return current game if tree update fails
224+
return currentGame
225+
}
226+
227+
// Update available moves and evaluations arrays
228+
const updatedAvailableMoves = [...currentGame.availableMoves, {}]
229+
const updatedMaiaEvaluations = [...currentGame.maiaEvaluations, {}]
230+
const updatedStockfishEvaluations = [
231+
...currentGame.stockfishEvaluations,
232+
undefined,
233+
] as (StockfishEvaluation | undefined)[]
234+
235+
return {
236+
...currentGame,
237+
moves: updatedMoves,
238+
availableMoves: updatedAvailableMoves,
239+
maiaEvaluations: updatedMaiaEvaluations,
240+
stockfishEvaluations: updatedStockfishEvaluations,
241+
}
242+
}

0 commit comments

Comments
 (0)