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
2 changes: 1 addition & 1 deletion __tests__/components/PlayerInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('PlayerInfo Component', () => {
it('should render player rating when provided', () => {
render(<PlayerInfo {...defaultProps} rating={1500} />)

expect(screen.getByText('TestPlayer (1500)')).toBeInTheDocument()
expect(screen.getByText('(1500)')).toBeInTheDocument()
})

it('should not render rating when not provided', () => {
Expand Down
1 change: 1 addition & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export * from './turing'
export * from './play'
export * from './profile'
export * from './opening'
export * from './lichess'
export { getActiveUserCount } from './home'
1 change: 1 addition & 0 deletions src/api/lichess/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './streaming'
242 changes: 242 additions & 0 deletions src/api/lichess/streaming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Chess } from 'chess.ts'
import { GameTree } from 'src/types/base/tree'
import { AvailableMoves } from 'src/types/training'
import {
LiveGame,
Player,
StockfishEvaluation,
StreamedGame,
StreamedMove,
} from 'src/types'

const readStream = (processLine: (data: any) => void) => (response: any) => {
const stream = response.body.getReader()
const matcher = /\r?\n/
const decoder = new TextDecoder()
let buf = ''

const loop = () =>
stream.read().then(({ done, value }: { done: boolean; value: any }) => {
if (done) {
if (buf.length > 0) processLine(JSON.parse(buf))
} else {
const chunk = decoder.decode(value, {
stream: true,
})
buf += chunk

const parts = (buf || '').split(matcher)
buf = parts.pop() as string
for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))

return loop()
}
})

return loop()
}

export const getLichessTVGame = async () => {
const res = await fetch('https://lichess.org/api/tv/channels')
if (!res.ok) {
throw new Error('Failed to fetch Lichess TV data')
}
const data = await res.json()

// Return the best rapid game (highest rated players)
const bestChannel = data.rapid
if (!bestChannel?.gameId) {
throw new Error('No TV game available')
}

return {
gameId: bestChannel.gameId,
white: bestChannel.user1,
black: bestChannel.user2,
}
}

export const getLichessGameInfo = async (gameId: string) => {
const res = await fetch(`https://lichess.org/api/game/${gameId}`)
if (!res.ok) {
throw new Error(`Failed to fetch game info for ${gameId}`)
}
return res.json()
}

export const streamLichessGame = async (
gameId: string,
onGameInfo: (data: StreamedGame) => void,
onMove: (data: StreamedMove) => void,
onComplete: () => void,
abortSignal?: AbortSignal,
) => {
const stream = fetch(`https://lichess.org/api/stream/game/${gameId}`, {
signal: abortSignal,
headers: {
Accept: 'application/x-ndjson',
},
})

const onMessage = (message: any) => {
if (message.id) {
onGameInfo(message as StreamedGame)
} else if (message.uci || message.lm) {
onMove({
fen: message.fen,
uci: message.uci || message.lm,
wc: message.wc,
bc: message.bc,
})
} else {
console.log('Unknown message format:', message)
}
}

try {
const response = await stream

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}

if (!response.body) {
throw new Error('No response body')
}

await readStream(onMessage)(response).then(onComplete)
} catch (error) {
if (abortSignal?.aborted) {
console.log('Stream aborted')
} else {
console.error('Stream error:', error)
throw error
}
}
}

export const createAnalyzedGameFromLichessStream = (
gameData: any,
): LiveGame => {
const { players, id } = gameData

const whitePlayer: Player = {
name: players?.white?.user?.id || 'White',
rating: players?.white?.rating,
}

const blackPlayer: Player = {
name: players?.black?.user?.id || 'Black',
rating: players?.black?.rating,
}

const startingFen =
gameData.initialFen ||
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'

const gameStates = [
{
board: startingFen,
lastMove: undefined as [string, string] | undefined,
san: undefined as string | undefined,
check: false as const,
maia_values: {},
},
]

const tree = new GameTree(startingFen)

return {
id,
blackPlayer,
whitePlayer,
gameType: 'stream',
type: 'stream' as const,
moves: gameStates,
availableMoves: new Array(gameStates.length).fill({}) as AvailableMoves[],
termination: undefined,
maiaEvaluations: [],
stockfishEvaluations: [],
loadedFen: gameData.fen,
loaded: false,
tree,
} as LiveGame
}

export const parseLichessStreamMove = (
moveData: StreamedMove,
currentGame: LiveGame,
) => {
const { uci, fen } = moveData

if (!uci || !fen || !currentGame.tree) {
return currentGame
}

// Convert UCI to SAN notation using chess.js
let san = uci // Fallback to UCI
try {
// Get the position before this move by finding the last node in the tree
let beforeMoveNode = currentGame.tree.getRoot()
while (beforeMoveNode.mainChild) {
beforeMoveNode = beforeMoveNode.mainChild
}

const chess = new Chess(beforeMoveNode.fen)
const move = chess.move({
from: uci.slice(0, 2),
to: uci.slice(2, 4),
promotion: uci[4] ? (uci[4] as any) : undefined,
})

if (move) {
san = move.san
}
} catch (error) {
console.warn('Could not convert UCI to SAN:', error)
// Keep UCI as fallback
}

// Create new move object
const newMove = {
board: fen,
lastMove: [uci.slice(0, 2), uci.slice(2, 4)] as [string, string],
san: san,
check: false as const, // We'd need to calculate this from the FEN
maia_values: {},
}

// Add to moves array
const updatedMoves = [...currentGame.moves, newMove]

// Add to tree mainline - find the last node in the main line
let currentNode = currentGame.tree.getRoot()
while (currentNode.mainChild) {
currentNode = currentNode.mainChild
}

try {
currentGame.tree.addMainMove(currentNode, fen, uci, san)
} catch (error) {
console.error('Error adding move to tree:', error)
// Return current game if tree update fails
return currentGame
}

// Update available moves and evaluations arrays
const updatedAvailableMoves = [...currentGame.availableMoves, {}]
const updatedMaiaEvaluations = [...currentGame.maiaEvaluations, {}]
const updatedStockfishEvaluations = [
...currentGame.stockfishEvaluations,
undefined,
] as (StockfishEvaluation | undefined)[]

return {
...currentGame,
moves: updatedMoves,
availableMoves: updatedAvailableMoves,
maiaEvaluations: updatedMaiaEvaluations,
stockfishEvaluations: updatedStockfishEvaluations,
}
}
Loading
Loading