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
11 changes: 9 additions & 2 deletions src/api/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,18 @@ export const storeCustomGame = async (data: {
body: JSON.stringify(data),
})

const bodyText = await res.text()

if (!res.ok) {
console.error(`Failed to store custom game: ${await res.text()}`)
console.error(`Failed to store custom game: ${bodyText}`)
throw new Error(
`Failed to store custom game (${res.status} ${res.statusText})${
bodyText ? `: ${bodyText}` : ''
}`,
)
}

return res.json()
return JSON.parse(bodyText)
}

export const deleteCustomGame = async (gameId: string): Promise<void> => {
Expand Down
124 changes: 119 additions & 5 deletions src/components/Analysis/CustomAnalysisModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,149 @@ interface Props {
onClose: () => void
}

const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/
const PGN_RESULT_TOKENS = new Set(['1-0', '0-1', '1/2-1/2', '*'])

const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
const normalizedNewlines = pgn.replace(/\r\n/g, '\n')
const lines = normalizedNewlines.split('\n')

let firstContentLine = 0
while (
firstContentLine < lines.length &&
lines[firstContentLine].trim().length === 0
) {
firstContentLine++
}

let headerEndLine = firstContentLine
while (
headerEndLine < lines.length &&
PGN_HEADER_LINE_REGEX.test(lines[headerEndLine])
) {
headerEndLine++
}

const hasHeaderBlock = headerEndLine > firstContentLine
const hasMovetextAfterHeaders = headerEndLine < lines.length
const needsSeparator =
hasHeaderBlock &&
hasMovetextAfterHeaders &&
lines[headerEndLine].trim().length > 0

if (needsSeparator) {
lines.splice(headerEndLine, 0, '')
}

return lines.join('\n').trim()
}

const formatMoveHistoryAsPgn = (moves: string[]): string => {
const pgnTokens: string[] = []

for (let i = 0; i < moves.length; i += 2) {
pgnTokens.push(`${Math.floor(i / 2) + 1}. ${moves[i]}`)
if (moves[i + 1]) {
pgnTokens.push(moves[i + 1])
}
}

return pgnTokens.join(' ').trim()
}

const extractPgnResultToken = (pgn: string): string | undefined => {
const headerResultMatch = pgn.match(
/\[\s*Result\s+"(1-0|0-1|1\/2-1\/2|\*)"\s*\]/i,
)
if (headerResultMatch) {
return headerResultMatch[1]
}

// Strip comments and variations before scanning for a terminal result token.
let movetext = pgn.replace(/\{[^}]*\}/g, ' ').replace(/;[^\r\n]*/g, ' ')

const ravRegex = /\([^()]*\)/g
while (ravRegex.test(movetext)) {
movetext = movetext.replace(ravRegex, ' ')
}

const tailMatch = movetext.match(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?:\s*)$/)
return tailMatch?.[1]
}

const normalizePgnForAnalysis = (input: string): string => {
const trimmed = input.trim()
const candidates = Array.from(
new Set([trimmed, ensureBlankLineAfterPgnHeaders(trimmed)]),
)

for (const candidate of candidates) {
for (const sloppy of [false, true]) {
const chess = new Chess()
const loaded = chess.loadPgn(candidate, { sloppy })

if (!loaded) continue

const header = chess.header()

// Preserve SetUp/FEN PGNs since the initial position cannot be represented
// by a move list alone.
if (header.SetUp === '1' && header.FEN) {
return chess.pgn()
}

const moveTextOnly = formatMoveHistoryAsPgn(chess.history())
if (!moveTextOnly) {
throw new Error('PGN must contain at least one move')
}

const resultToken =
(header.Result && PGN_RESULT_TOKENS.has(header.Result)
? header.Result
: extractPgnResultToken(candidate)) || undefined

return resultToken && resultToken !== '*'
? `${moveTextOnly} ${resultToken}`
: moveTextOnly
}
}

throw new Error(
'Unable to parse PGN. If using [Tag "..."] headers, include a blank line before the moves.',
)
}

export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
const [mode, setMode] = useState<'pgn' | 'fen'>('pgn')
const [input, setInput] = useState('')
const [name, setName] = useState('')

const validateAndSubmit = () => {
const trimmedInput = input.trim()

if (!input.trim()) {
toast.error('Please enter some data')
return
}

if (mode === 'fen') {
const chess = new Chess()
const validation = chess.validateFen(input.trim())
const validation = chess.validateFen(trimmedInput)
if (!validation.valid) {
toast.error('Invalid FEN position: ' + validation.error)
return
}

onSubmit(mode, trimmedInput, name.trim() || undefined)
} else {
try {
const chess = new Chess()
chess.loadPgn(input.trim())
const normalizedPgn = normalizePgnForAnalysis(trimmedInput)
onSubmit(mode, normalizedPgn, name.trim() || undefined)
} catch (error) {
toast.error('Invalid PGN format: ' + (error as Error).message)
return
}
}

onSubmit(mode, input.trim(), name.trim() || undefined)
}

const examplePGN = `1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Bb7 10. d4 Re8`
Expand Down
2 changes: 1 addition & 1 deletion src/components/Board/GameBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export const GameBoard: React.FC<Props> = ({
<MoveClassificationIcon
classification={badge.classification}
size="small"
className="!ml-0 !h-3.5 !w-3.5 !text-[9px] pointer-events-none"
className="pointer-events-none !ml-0 !h-3.5 !w-3.5 !text-[9px]"
/>
</div>
</div>
Expand Down
4 changes: 1 addition & 3 deletions src/components/Common/PlaySetupModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ function OptionSelect<T>({
<button
key={index}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
option === selected
? selectedClassName
: unselectedClassName
option === selected ? selectedClassName : unselectedClassName
} ${index === 0 ? 'rounded-l-lg' : ''} ${
index === options.length - 1 ? 'rounded-r-lg' : ''
}`}
Expand Down
6 changes: 1 addition & 5 deletions src/hooks/useAnalysisController/useAnalysisController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,7 @@ export const useAnalysisController = (
square: topHumanMove.slice(2, 4),
classification,
}
}, [
controller.currentNode,
moveEvaluation,
currentMaiaModel,
])
}, [controller.currentNode, moveEvaluation, currentMaiaModel])

return {
gameTree: controller.tree,
Expand Down
117 changes: 108 additions & 9 deletions src/pages/analysis/[...id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
MaiaEvaluation,
StockfishEvaluation,
GameNode,
Termination,
} from 'src/types'
import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts'
import { Loading } from 'src/components'
Expand Down Expand Up @@ -57,13 +58,91 @@ import { MAIA_MODELS } from 'src/constants/common'
import { applyEngineAnalysisData } from 'src/lib/analysis'

const EVAL_BAR_RANGE = 4
const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY =
'maia_custom_pgn_result_overrides'
const PGN_RESULT_TOKEN_REGEX = /(.*?)(?:\s+)(1-0|0-1|1\/2-1\/2|\*)\s*$/
const DEFAULT_STOCKFISH_EVAL_BAR = {
hasEval: false,
pawns: 0,
displayPawns: 0,
label: '--',
}

const splitTrailingPgnResult = (
pgn: string,
): { pgnWithoutResult: string; result?: string } => {
const match = pgn.match(PGN_RESULT_TOKEN_REGEX)
if (!match) {
return { pgnWithoutResult: pgn }
}

return {
pgnWithoutResult: match[1].trim(),
result: match[2],
}
}

const getCustomPgnResultOverrides = (): Record<string, string> => {
if (typeof window === 'undefined') return {}

try {
const raw = window.localStorage.getItem(
CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY,
)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch (error) {
console.warn('Failed to read custom PGN result overrides:', error)
return {}
}
}

const setCustomPgnResultOverride = (gameId: string, result: string): void => {
if (typeof window === 'undefined') return

try {
const overrides = getCustomPgnResultOverrides()
overrides[gameId] = result
window.localStorage.setItem(
CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY,
JSON.stringify(overrides),
)
} catch (error) {
console.warn('Failed to store custom PGN result override:', error)
}
}

const getCustomPgnResultOverride = (gameId: string): string | undefined => {
const override = getCustomPgnResultOverrides()[gameId]
return typeof override === 'string' ? override : undefined
}

const resultTokenToWinner = (
result: string,
): Termination['winner'] | undefined => {
if (result === '1-0') return 'white'
if (result === '0-1') return 'black'
if (result === '1/2-1/2') return 'none'
return undefined
}

const applyCustomResultOverride = (
game: AnalyzedGame,
result?: string,
): AnalyzedGame => {
if (!result || result === '*') return game

return {
...game,
termination: {
...(game.termination || {}),
result,
winner: resultTokenToWinner(result),
},
}
}

const AnalysisPage: NextPage = () => {
const { startTour, tourState } = useTour()

Expand Down Expand Up @@ -162,12 +241,16 @@ const AnalysisPage: NextPage = () => {
updateUrl = true,
) => {
const game = await fetchAnalyzedMaiaGame(id, type)
const gameWithOverrides =
type === 'custom'
? applyCustomResultOverride(game, getCustomPgnResultOverride(id))
: game

if (setCurrentMove) setCurrentMove(0)

setAnalyzedGame({ ...game, type })
setAnalyzedGame({ ...gameWithOverrides, type })
setCurrentId([id, type])
await loadGameAnalysisCache({ ...game, type })
await loadGameAnalysisCache({ ...gameWithOverrides, type })

if (updateUrl) {
router.push(`/analysis/${id}/${type}`, undefined, {
Expand Down Expand Up @@ -347,14 +430,30 @@ const Analysis: React.FC<Props> = ({
const handleCustomAnalysis = useCallback(
(type: 'fen' | 'pgn', data: string, name?: string) => {
;(async () => {
const { game_id } = await storeCustomGame({
name: name,
pgn: type === 'pgn' ? data : undefined,
fen: type === 'fen' ? data : undefined,
})
try {
const pgnPayload =
type === 'pgn' ? splitTrailingPgnResult(data) : undefined
const { game_id } = await storeCustomGame({
name: name,
pgn:
type === 'pgn' ? pgnPayload?.pgnWithoutResult || data : undefined,
fen: type === 'fen' ? data : undefined,
})

if (pgnPayload?.result && pgnPayload.result !== '*') {
setCustomPgnResultOverride(game_id, pgnPayload.result)
}

setShowCustomModal(false)
router.push(`/analysis/${game_id}/custom`)
setShowCustomModal(false)
router.push(`/analysis/${game_id}/custom`)
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Failed to store custom game'
console.error('Custom analysis import failed:', error)
toast.error(message)
}
})()
},
[],
Expand Down
Loading