Skip to content

Commit f0e10bc

Browse files
feat: add custom game analysis modal
1 parent ec1c8b6 commit f0e10bc

11 files changed

Lines changed: 628 additions & 11 deletions

File tree

src/api/analysis/analysis.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,160 @@ export const getAnalyzedLichessGame = async (id: string, pgn: string) => {
387387
} as AnalyzedGame
388388
}
389389

390+
const createAnalyzedGameFromPGN = async (
391+
pgn: string,
392+
id?: string,
393+
): Promise<AnalyzedGame> => {
394+
const { Chess } = await import('chess.ts')
395+
const chess = new Chess()
396+
397+
try {
398+
chess.loadPgn(pgn)
399+
} catch (error) {
400+
throw new Error('Invalid PGN format')
401+
}
402+
403+
const history = chess.history({ verbose: true })
404+
const headers = chess.header()
405+
406+
const moves = []
407+
const tempChess = new Chess()
408+
409+
const startingFen =
410+
headers.FEN || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
411+
if (headers.FEN) {
412+
tempChess.load(headers.FEN)
413+
}
414+
415+
moves.push({
416+
board: tempChess.fen(),
417+
lastMove: undefined,
418+
san: undefined,
419+
check: tempChess.inCheck(),
420+
maia_values: {},
421+
})
422+
423+
for (const move of history) {
424+
tempChess.move(move)
425+
moves.push({
426+
board: tempChess.fen(),
427+
lastMove: [move.from, move.to] as [string, string],
428+
san: move.san,
429+
check: tempChess.inCheck(),
430+
maia_values: {},
431+
})
432+
}
433+
434+
const tree = buildGameTree(moves, startingFen)
435+
436+
return {
437+
id: id || `custom-pgn-${Date.now()}`,
438+
blackPlayer: { name: headers.Black || 'Black', rating: undefined },
439+
whitePlayer: { name: headers.White || 'White', rating: undefined },
440+
moves,
441+
availableMoves: new Array(moves.length).fill({}),
442+
gameType: 'custom',
443+
termination: {
444+
result: headers.Result || '*',
445+
winner:
446+
headers.Result === '1-0'
447+
? 'white'
448+
: headers.Result === '0-1'
449+
? 'black'
450+
: 'none',
451+
condition: 'Normal',
452+
},
453+
maiaEvaluations: new Array(moves.length).fill({}),
454+
stockfishEvaluations: new Array(moves.length).fill(undefined),
455+
tree,
456+
type: 'custom-pgn' as const,
457+
pgn,
458+
} as AnalyzedGame
459+
}
460+
461+
export const getAnalyzedCustomPGN = async (
462+
pgn: string,
463+
name?: string,
464+
): Promise<AnalyzedGame> => {
465+
const { saveCustomAnalysis } = await import('src/utils/customAnalysis')
466+
467+
const stored = saveCustomAnalysis('pgn', pgn, name)
468+
469+
return createAnalyzedGameFromPGN(pgn, stored.id)
470+
}
471+
472+
const createAnalyzedGameFromFEN = async (
473+
fen: string,
474+
id?: string,
475+
): Promise<AnalyzedGame> => {
476+
const { Chess } = await import('chess.ts')
477+
const chess = new Chess()
478+
479+
try {
480+
chess.load(fen)
481+
} catch (error) {
482+
throw new Error('Invalid FEN format')
483+
}
484+
485+
const moves = [
486+
{
487+
board: fen,
488+
lastMove: undefined,
489+
san: undefined,
490+
check: chess.inCheck(),
491+
maia_values: {},
492+
},
493+
]
494+
495+
const tree = new GameTree(fen)
496+
497+
return {
498+
id: id || `custom-fen-${Date.now()}`,
499+
blackPlayer: { name: 'Black', rating: undefined },
500+
whitePlayer: { name: 'White', rating: undefined },
501+
moves,
502+
availableMoves: [{}],
503+
gameType: 'custom',
504+
termination: {
505+
result: '*',
506+
winner: 'none',
507+
condition: 'Normal',
508+
},
509+
maiaEvaluations: [{}],
510+
stockfishEvaluations: [undefined],
511+
tree,
512+
type: 'custom-fen' as const,
513+
} as AnalyzedGame
514+
}
515+
516+
export const getAnalyzedCustomFEN = async (
517+
fen: string,
518+
name?: string,
519+
): Promise<AnalyzedGame> => {
520+
const { saveCustomAnalysis } = await import('src/utils/customAnalysis')
521+
522+
const stored = saveCustomAnalysis('fen', fen, name)
523+
524+
return createAnalyzedGameFromFEN(fen, stored.id)
525+
}
526+
527+
export const getAnalyzedCustomGame = async (
528+
id: string,
529+
): Promise<AnalyzedGame> => {
530+
const { getCustomAnalysisById } = await import('src/utils/customAnalysis')
531+
532+
const stored = getCustomAnalysisById(id)
533+
if (!stored) {
534+
throw new Error('Custom analysis not found')
535+
}
536+
537+
if (stored.type === 'custom-pgn') {
538+
return createAnalyzedGameFromPGN(stored.data, stored.id)
539+
} else {
540+
return createAnalyzedGameFromFEN(stored.data, stored.id)
541+
}
542+
}
543+
390544
export const getAnalyzedUserGame = async (
391545
id: string,
392546
game_type: 'play' | 'hand' | 'brain',

src/components/Analysis/AnalysisGameList.tsx

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { motion } from 'framer-motion'
1111
import { Tournament } from 'src/components'
1212
import { AnalysisListContext } from 'src/contexts'
1313
import { getAnalysisGameList } from 'src/api'
14+
import { getCustomAnalysesAsWebGames } from 'src/utils/customAnalysis'
1415

1516
interface GameData {
1617
game_id: string
@@ -35,13 +36,22 @@ interface AnalysisGameListProps {
3536
type: 'play' | 'hand' | 'brain',
3637
setCurrentMove?: Dispatch<SetStateAction<number>>,
3738
) => Promise<void>
39+
loadNewCustomGame: (
40+
id: string,
41+
setCurrentMove?: Dispatch<SetStateAction<number>>,
42+
) => Promise<void>
43+
onCustomAnalysis?: () => void
44+
refreshTrigger?: number // Used to trigger refresh when custom analysis is added
3845
}
3946

4047
export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
4148
currentId,
4249
loadNewTournamentGame,
4350
loadNewLichessGames,
4451
loadNewUserGames,
52+
loadNewCustomGame,
53+
onCustomAnalysis,
54+
refreshTrigger,
4555
}) => {
4656
const {
4757
analysisPlayList,
@@ -57,6 +67,22 @@ export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
5767
const [localPlayGames, setLocalPlayGames] = useState(analysisPlayList)
5868
const [localHandGames, setLocalHandGames] = useState(analysisHandList)
5969
const [localBrainGames, setLocalBrainGames] = useState(analysisBrainList)
70+
const [customAnalyses, setCustomAnalyses] = useState(() => {
71+
if (typeof window !== 'undefined') {
72+
return getCustomAnalysesAsWebGames()
73+
}
74+
return []
75+
})
76+
77+
useEffect(() => {
78+
setCustomAnalyses(getCustomAnalysesAsWebGames())
79+
}, [refreshTrigger])
80+
81+
useEffect(() => {
82+
if (currentId?.[0]?.startsWith('custom-')) {
83+
setSelected('pgn')
84+
}
85+
}, [currentId])
6086

6187
const [fetchedCache, setFetchedCache] = useState<{
6288
[key: string]: { [page: number]: boolean }
@@ -102,11 +128,18 @@ export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
102128

103129
const [selected, setSelected] = useState<
104130
'tournament' | 'pgn' | 'play' | 'hand' | 'brain'
105-
>(
106-
['pgn', 'play', 'hand', 'brain'].includes(currentId?.[1] ?? '')
107-
? (currentId?.[1] as 'pgn' | 'play' | 'hand' | 'brain')
108-
: 'tournament',
109-
)
131+
>(() => {
132+
// Check if currentId is a custom game (starts with 'custom-')
133+
if (currentId?.[0]?.startsWith('custom-')) {
134+
return 'pgn' // Custom games are in the pgn/Custom tab
135+
}
136+
// Check if it's one of the other specific types
137+
if (['pgn', 'play', 'hand', 'brain'].includes(currentId?.[1] ?? '')) {
138+
return currentId?.[1] as 'pgn' | 'play' | 'hand' | 'brain'
139+
}
140+
141+
return 'tournament'
142+
})
110143
const [loadingIndex, setLoadingIndex] = useState<number | null>(null)
111144
const [openIndex, setOpenIndex] = useState<number | null>(initialOpenIndex)
112145

@@ -240,7 +273,7 @@ export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
240273
setSelected={handleTabChange}
241274
/>
242275
<Header
243-
label="Lichess"
276+
label="Custom"
244277
name="pgn"
245278
selected={selected}
246279
setSelected={handleTabChange}
@@ -290,7 +323,7 @@ export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
290323
? localHandGames
291324
: selected === 'brain'
292325
? localBrainGames
293-
: analysisLichessList
326+
: [...customAnalyses, ...analysisLichessList]
294327
).map((game, index) => {
295328
const selectedGame = currentId && currentId[0] === game.id
296329
return (
@@ -303,6 +336,11 @@ export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
303336
game.id,
304337
game.pgn as string,
305338
)
339+
} else if (
340+
game.type === 'custom-pgn' ||
341+
game.type === 'custom-fen'
342+
) {
343+
await loadNewCustomGame(game.id)
306344
} else {
307345
await loadNewUserGames(
308346
game.id,
@@ -384,6 +422,19 @@ export const AnalysisGameList: React.FC<AnalysisGameListProps> = ({
384422
<p className="ml-2 text-xs text-secondary">₍^. .^₎⟆</p>
385423
</div>
386424
</div>
425+
{onCustomAnalysis && (
426+
<button
427+
onClick={onCustomAnalysis}
428+
className="flex w-full items-center gap-2 bg-background-4/40 px-3 py-1.5 transition duration-200 hover:bg-background-4/80"
429+
>
430+
<span className="material-symbols-outlined text-xs text-secondary">
431+
add
432+
</span>
433+
<span className="text-xs text-secondary">
434+
Analyze Custom PGN/FEN
435+
</span>
436+
</button>
437+
)}
387438
</div>
388439
</div>
389440
) : null

src/components/Analysis/ConfigurableScreens.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface Props {
1111
MAIA_MODELS: string[]
1212
game: AnalyzedGame
1313
currentNode: GameNode
14+
onDeleteCustomGame?: () => void
1415
}
1516

1617
export const ConfigurableScreens: React.FC<Props> = ({
@@ -20,6 +21,7 @@ export const ConfigurableScreens: React.FC<Props> = ({
2021
MAIA_MODELS,
2122
game,
2223
currentNode,
24+
onDeleteCustomGame,
2325
}) => {
2426
const screens = [
2527
{
@@ -72,6 +74,8 @@ export const ConfigurableScreens: React.FC<Props> = ({
7274
setCurrentMaiaModel={setCurrentMaiaModel}
7375
launchContinue={launchContinue}
7476
MAIA_MODELS={MAIA_MODELS}
77+
game={game}
78+
onDeleteCustomGame={onDeleteCustomGame}
7579
/>
7680
) : screen.id === 'export' ? (
7781
<div className="flex w-full flex-col p-4">

src/components/Analysis/ConfigureAnalysis.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from 'next/link'
22
import React from 'react'
33
import { useLocalStorage } from 'src/hooks'
4+
import { AnalyzedGame } from 'src/types'
45

56
import { ContinueAgainstMaia } from 'src/components'
67

@@ -9,14 +10,20 @@ interface Props {
910
setCurrentMaiaModel: (model: string) => void
1011
launchContinue: () => void
1112
MAIA_MODELS: string[]
13+
game: AnalyzedGame
14+
onDeleteCustomGame?: () => void
1215
}
1316

1417
export const ConfigureAnalysis: React.FC<Props> = ({
1518
currentMaiaModel,
1619
setCurrentMaiaModel,
1720
launchContinue,
1821
MAIA_MODELS,
22+
game,
23+
onDeleteCustomGame,
1924
}: Props) => {
25+
const isCustomGame = game.type === 'custom-pgn' || game.type === 'custom-fen'
26+
2027
return (
2128
<div className="flex w-full flex-col items-start justify-start gap-1 p-4">
2229
<div className="flex w-full flex-col gap-0.5">
@@ -37,6 +44,16 @@ export const ConfigureAnalysis: React.FC<Props> = ({
3744
launchContinue={launchContinue}
3845
background="bg-human-4/60 hover:bg-human-4/80 text-primary/70 hover:text-primary !px-2 !py-1.5 !text-sm"
3946
/>
47+
{isCustomGame && onDeleteCustomGame && (
48+
<div className="mt-2 w-full">
49+
<button
50+
onClick={onDeleteCustomGame}
51+
className="text-xs text-secondary transition duration-200 hover:text-human-4"
52+
>
53+
<span className="underline">Delete</span> this stored Custom Game
54+
</button>
55+
</div>
56+
)}
4057
</div>
4158
)
4259
}

0 commit comments

Comments
 (0)