Skip to content

Commit 630faad

Browse files
feat: add initial iteration of stream page
1 parent 08d85f0 commit 630faad

File tree

9 files changed

+1515
-0
lines changed

9 files changed

+1515
-0
lines changed

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: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { Chess } from 'chess.ts'
3+
import { AnalyzedGame, Player, StockfishEvaluation } from 'src/types'
4+
import { GameTree } from 'src/types/base/tree'
5+
import { AvailableMoves } from 'src/types/training'
6+
7+
// Re-use the readStream utility from analysis.ts
8+
const readStream = (processLine: (data: any) => void) => (response: any) => {
9+
const stream = response.body.getReader()
10+
const matcher = /\r?\n/
11+
const decoder = new TextDecoder()
12+
let buf = ''
13+
14+
const loop = () =>
15+
stream.read().then(({ done, value }: { done: boolean; value: any }) => {
16+
if (done) {
17+
if (buf.length > 0) processLine(JSON.parse(buf))
18+
} else {
19+
const chunk = decoder.decode(value, {
20+
stream: true,
21+
})
22+
buf += chunk
23+
24+
const parts = (buf || '').split(matcher)
25+
buf = parts.pop() as string
26+
for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
27+
28+
return loop()
29+
}
30+
})
31+
32+
return loop()
33+
}
34+
35+
// Get current Lichess TV game information
36+
export const getLichessTVGame = async () => {
37+
const res = await fetch('https://lichess.org/api/tv/channels')
38+
if (!res.ok) {
39+
throw new Error('Failed to fetch Lichess TV data')
40+
}
41+
const data = await res.json()
42+
43+
// Return the best game (highest rated players)
44+
const bestChannel = data.best
45+
if (!bestChannel?.gameId) {
46+
throw new Error('No TV game available')
47+
}
48+
49+
return {
50+
gameId: bestChannel.gameId,
51+
white: bestChannel.user1,
52+
black: bestChannel.user2,
53+
}
54+
}
55+
56+
// Get basic game information from Lichess API
57+
export const getLichessGameInfo = async (gameId: string) => {
58+
const res = await fetch(`https://lichess.org/api/game/${gameId}`)
59+
if (!res.ok) {
60+
throw new Error(`Failed to fetch game info for ${gameId}`)
61+
}
62+
return res.json()
63+
}
64+
65+
// Stream live moves from a Lichess game
66+
export const streamLichessGame = async (
67+
gameId: string,
68+
onGameStart: (data: any) => void,
69+
onMove: (data: any) => void,
70+
onComplete: () => void,
71+
abortSignal?: AbortSignal,
72+
) => {
73+
console.log(`Starting stream for game ${gameId}`)
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+
console.log('Raw message received:', message)
84+
85+
if (message.id) {
86+
// This is the initial game state with full game info
87+
console.log('Game start message:', message)
88+
onGameStart(message)
89+
} else if (message.uci || message.lm) {
90+
// This is a move - handle both formats: {"fen":"...", "uci":"e2e4"} or {"fen":"...", "lm":"e2e4"}
91+
onMove({
92+
fen: message.fen,
93+
uci: message.uci || message.lm,
94+
wc: message.wc,
95+
bc: message.bc,
96+
})
97+
} else if (message.fen && !message.uci && !message.lm) {
98+
// This is the initial position - ignore this message
99+
console.log('Initial position received:', message)
100+
} else {
101+
console.log('Unknown message format:', message)
102+
}
103+
}
104+
105+
try {
106+
const response = await stream
107+
108+
if (!response.ok) {
109+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
110+
}
111+
112+
if (!response.body) {
113+
throw new Error('No response body')
114+
}
115+
116+
await readStream(onMessage)(response).then(onComplete)
117+
} catch (error) {
118+
if (abortSignal?.aborted) {
119+
console.log('Stream aborted')
120+
} else {
121+
console.error('Stream error:', error)
122+
throw error
123+
}
124+
}
125+
}
126+
127+
// Convert Lichess game data to our AnalyzedGame format for live streaming
128+
export const createAnalyzedGameFromLichessStream = (
129+
gameData: any,
130+
): AnalyzedGame => {
131+
const { players, fen, id } = gameData
132+
133+
const whitePlayer: Player = {
134+
name: players.white?.user?.name || 'White',
135+
rating: players.white?.rating,
136+
}
137+
138+
const blackPlayer: Player = {
139+
name: players.black?.user?.name || 'Black',
140+
rating: players.black?.rating,
141+
}
142+
143+
// Use the current FEN from the game data (this is the current position)
144+
const startingFen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
145+
const currentFen = fen || startingFen
146+
147+
// Build moves array - start with just the initial position
148+
// We'll build up the game tree as moves come in via the stream
149+
const gameStates = [
150+
{
151+
board: startingFen,
152+
lastMove: undefined as [string, string] | undefined,
153+
san: undefined as string | undefined,
154+
check: false as const,
155+
maia_values: {},
156+
},
157+
]
158+
159+
// If we have the current position FEN and it's different from starting position,
160+
// we know the game is in progress, but we don't have the move history
161+
if (currentFen !== startingFen) {
162+
// Game is in progress - we'll show the current position
163+
// but we won't have move history until new moves come in
164+
gameStates.push({
165+
board: currentFen,
166+
lastMove: gameData.lastMove
167+
? ([gameData.lastMove.slice(0, 2), gameData.lastMove.slice(2, 4)] as [
168+
string,
169+
string,
170+
])
171+
: undefined,
172+
san: gameData.lastMove || '', // We don't have SAN notation from Lichess stream initially
173+
check: false as const,
174+
maia_values: {},
175+
})
176+
}
177+
178+
// Create game tree starting from the beginning
179+
const tree = new GameTree(startingFen)
180+
181+
return {
182+
id: `stream-${id}`,
183+
blackPlayer,
184+
whitePlayer,
185+
moves: gameStates,
186+
availableMoves: new Array(gameStates.length).fill({}) as AvailableMoves[],
187+
gameType: 'blitz', // Default to blitz, could be detected from game data
188+
termination: {
189+
result: '*', // Live game in progress
190+
winner: undefined,
191+
condition: 'Live',
192+
},
193+
maiaEvaluations: new Array(gameStates.length).fill({}),
194+
stockfishEvaluations: new Array(gameStates.length).fill(undefined) as (
195+
| StockfishEvaluation
196+
| undefined
197+
)[],
198+
tree,
199+
type: 'stream' as const, // Use stream type for live streams
200+
} as AnalyzedGame
201+
}
202+
203+
// Parse a move from the Lichess stream format and update game state
204+
export const parseLichessStreamMove = (
205+
moveData: any,
206+
currentGame: AnalyzedGame,
207+
) => {
208+
const { uci, fen } = moveData
209+
210+
if (!uci || !fen || !currentGame.tree) {
211+
return currentGame
212+
}
213+
214+
// Convert UCI to SAN notation using chess.js
215+
let san = uci // Fallback to UCI
216+
try {
217+
// Get the position before this move by finding the last node in the tree
218+
let beforeMoveNode = currentGame.tree.getRoot()
219+
while (beforeMoveNode.mainChild) {
220+
beforeMoveNode = beforeMoveNode.mainChild
221+
}
222+
223+
const chess = new Chess(beforeMoveNode.fen)
224+
const move = chess.move({
225+
from: uci.slice(0, 2),
226+
to: uci.slice(2, 4),
227+
promotion: uci[4] ? (uci[4] as any) : undefined,
228+
})
229+
230+
if (move) {
231+
san = move.san
232+
}
233+
} catch (error) {
234+
console.warn('Could not convert UCI to SAN:', error)
235+
// Keep UCI as fallback
236+
}
237+
238+
// Create new move object
239+
const newMove = {
240+
board: fen,
241+
lastMove: [uci.slice(0, 2), uci.slice(2, 4)] as [string, string],
242+
san: san,
243+
check: false as const, // We'd need to calculate this from the FEN
244+
maia_values: {},
245+
}
246+
247+
// Add to moves array
248+
const updatedMoves = [...currentGame.moves, newMove]
249+
250+
// Add to tree mainline - find the last node in the main line
251+
let currentNode = currentGame.tree.getRoot()
252+
while (currentNode.mainChild) {
253+
currentNode = currentNode.mainChild
254+
}
255+
256+
try {
257+
currentGame.tree.addMainMove(currentNode, fen, uci, san)
258+
} catch (error) {
259+
console.error('Error adding move to tree:', error)
260+
// Return current game if tree update fails
261+
return currentGame
262+
}
263+
264+
// Update available moves and evaluations arrays
265+
const updatedAvailableMoves = [...currentGame.availableMoves, {}]
266+
const updatedMaiaEvaluations = [...currentGame.maiaEvaluations, {}]
267+
const updatedStockfishEvaluations = [
268+
...currentGame.stockfishEvaluations,
269+
undefined,
270+
] as (StockfishEvaluation | undefined)[]
271+
272+
return {
273+
...currentGame,
274+
moves: updatedMoves,
275+
availableMoves: updatedAvailableMoves,
276+
maiaEvaluations: updatedMaiaEvaluations,
277+
stockfishEvaluations: updatedStockfishEvaluations,
278+
}
279+
}

0 commit comments

Comments
 (0)