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
105 changes: 20 additions & 85 deletions src/components/Analysis/CustomAnalysisModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,7 @@ import { motion } from 'framer-motion'
import { Chess } from 'chess.ts'
import toast from 'react-hot-toast'

interface Props {
onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void
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')
Expand Down Expand Up @@ -42,82 +36,12 @@ const ensureBlankLineAfterPgnHeaders = (pgn: string): string => {
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]
return lines.join('\n')
}

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.',
)
interface Props {
onSubmit: (type: 'pgn' | 'fen', data: string, name?: string) => void
onClose: () => void
}

export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
Expand All @@ -128,7 +52,7 @@ export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
const validateAndSubmit = () => {
const trimmedInput = input.trim()

if (!input.trim()) {
if (!trimmedInput) {
toast.error('Please enter some data')
return
}
Expand All @@ -140,17 +64,28 @@ export const CustomAnalysisModal: React.FC<Props> = ({ onSubmit, onClose }) => {
toast.error('Invalid FEN position: ' + validation.error)
return
}

onSubmit(mode, trimmedInput, name.trim() || undefined)
} else {
try {
const normalizedPgn = normalizePgnForAnalysis(trimmedInput)
onSubmit(mode, normalizedPgn, name.trim() || undefined)
const candidates = Array.from(
new Set([trimmedInput, ensureBlankLineAfterPgnHeaders(trimmedInput)]),
)
const isValid = candidates.some((candidate) => {
const chess = new Chess()
return chess.loadPgn(candidate, { sloppy: true })
})

if (!isValid) {
throw new Error(
'Unable to parse PGN. If using [Tag \"...\"] headers, include a blank line before the moves.',
)
}
} catch (error) {
toast.error('Invalid PGN format: ' + (error as Error).message)
return
}
}

onSubmit(mode, trimmedInput, 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
4 changes: 2 additions & 2 deletions src/components/Board/MovesContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ export const MovesContainer: React.FC<
{termination.result}
{', '}
{termination.winner !== 'none'
? `${termination.winner} is victorious`
? `${termination.winner} wins`
: 'draw'}
</div>
)}
Expand Down Expand Up @@ -486,7 +486,7 @@ export const MovesContainer: React.FC<
{termination.result}
{', '}
{termination.winner !== 'none'
? `${termination.winner} is victorious`
? `${termination.winner} wins`
: 'draw'}
</div>
)}
Expand Down
137 changes: 101 additions & 36 deletions src/pages/analysis/[...id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
MaiaEvaluation,
StockfishEvaluation,
GameNode,
Termination,
} from 'src/types'
import { WindowSizeContext, TreeControllerContext, useTour } from 'src/contexts'
import { Loading } from 'src/components'
Expand Down Expand Up @@ -60,26 +59,99 @@ 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 }
const PGN_HEADER_LINE_REGEX = /^\s*\[[^\]]+\]\s*$/

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++
}

return {
pgnWithoutResult: match[1].trim(),
result: match[2],
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 tokens: string[] = []

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

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

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

for (const candidate of candidates) {
const chess = new Chess()
if (!chess.loadPgn(candidate, { sloppy: true })) {
continue
}

const header = { ...chess.header() }

const moveText = formatMoveHistoryAsPgn(chess.history())
const headerText = Object.entries(header)
.map(([key, value]) => `[${key} "${value}"]`)
.join('\n')

if (!headerText) {
return moveText
}

return moveText ? `${headerText}\n\n${moveText}` : headerText
}

return ensureBlankLineAfterPgnHeaders(trimmed)
}

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

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

const getCustomPgnResultOverrides = (): Record<string, string> => {
Expand Down Expand Up @@ -113,24 +185,17 @@ const setCustomPgnResultOverride = (gameId: string, result: string): void => {
}
}

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

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

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

return {
Expand Down Expand Up @@ -240,17 +305,15 @@ const AnalysisPage: NextPage = () => {
setCurrentMove?: Dispatch<SetStateAction<number>>,
updateUrl = true,
) => {
const game = await fetchAnalyzedMaiaGame(id, type)
const gameWithOverrides =
type === 'custom'
? applyCustomResultOverride(game, getCustomPgnResultOverride(id))
: game
const rawGame = await fetchAnalyzedMaiaGame(id, type)
const game =
type === 'custom' ? applyCustomPgnResultOverride(id, rawGame) : rawGame

if (setCurrentMove) setCurrentMove(0)

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

if (updateUrl) {
router.push(`/analysis/${id}/${type}`, undefined, {
Expand Down Expand Up @@ -431,17 +494,19 @@ const Analysis: React.FC<Props> = ({
(type: 'fen' | 'pgn', data: string, name?: string) => {
;(async () => {
try {
const pgnPayload =
type === 'pgn' ? splitTrailingPgnResult(data) : undefined
const pgnResult =
type === 'pgn' ? extractPgnResultToken(data) : undefined
const { game_id } = await storeCustomGame({
name: name,
pgn:
type === 'pgn' ? pgnPayload?.pgnWithoutResult || data : undefined,
type === 'pgn'
? normalizeCustomPgnForBackendStore(data)
: undefined,
fen: type === 'fen' ? data : undefined,
})

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

setShowCustomModal(false)
Expand All @@ -456,7 +521,7 @@ const Analysis: React.FC<Props> = ({
}
})()
},
[],
[router],
)

const handleLearnFromMistakes = useCallback(() => {
Expand Down
Loading