@@ -20,7 +20,6 @@ import {
2020 MaiaEvaluation ,
2121 StockfishEvaluation ,
2222 GameNode ,
23- Termination ,
2423} from 'src/types'
2524import { WindowSizeContext , TreeControllerContext , useTour } from 'src/contexts'
2625import { Loading } from 'src/components'
@@ -60,26 +59,99 @@ import { applyEngineAnalysisData } from 'src/lib/analysis'
6059const EVAL_BAR_RANGE = 4
6160const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY =
6261 'maia_custom_pgn_result_overrides'
63- const PGN_RESULT_TOKEN_REGEX = / ( .* ?) (?: \s + ) ( 1 - 0 | 0 - 1 | 1 \/ 2 - 1 \/ 2 | \* ) \s * $ /
6462const DEFAULT_STOCKFISH_EVAL_BAR = {
6563 hasEval : false ,
6664 pawns : 0 ,
6765 displayPawns : 0 ,
6866 label : '--' ,
6967}
7068
71- const splitTrailingPgnResult = (
72- pgn : string ,
73- ) : { pgnWithoutResult : string ; result ?: string } => {
74- const match = pgn . match ( PGN_RESULT_TOKEN_REGEX )
75- if ( ! match ) {
76- return { pgnWithoutResult : pgn }
69+ const PGN_HEADER_LINE_REGEX = / ^ \s * \[ [ ^ \] ] + \] \s * $ /
70+
71+ const ensureBlankLineAfterPgnHeaders = ( pgn : string ) : string => {
72+ const normalizedNewlines = pgn . replace ( / \r \n / g, '\n' )
73+ const lines = normalizedNewlines . split ( '\n' )
74+
75+ let firstContentLine = 0
76+ while (
77+ firstContentLine < lines . length &&
78+ lines [ firstContentLine ] . trim ( ) . length === 0
79+ ) {
80+ firstContentLine ++
7781 }
7882
79- return {
80- pgnWithoutResult : match [ 1 ] . trim ( ) ,
81- result : match [ 2 ] ,
83+ let headerEndLine = firstContentLine
84+ while (
85+ headerEndLine < lines . length &&
86+ PGN_HEADER_LINE_REGEX . test ( lines [ headerEndLine ] )
87+ ) {
88+ headerEndLine ++
89+ }
90+
91+ const hasHeaderBlock = headerEndLine > firstContentLine
92+ const hasMovetextAfterHeaders = headerEndLine < lines . length
93+ const needsSeparator =
94+ hasHeaderBlock &&
95+ hasMovetextAfterHeaders &&
96+ lines [ headerEndLine ] . trim ( ) . length > 0
97+
98+ if ( needsSeparator ) {
99+ lines . splice ( headerEndLine , 0 , '' )
100+ }
101+
102+ return lines . join ( '\n' ) . trim ( )
103+ }
104+
105+ const formatMoveHistoryAsPgn = ( moves : string [ ] ) : string => {
106+ const tokens : string [ ] = [ ]
107+
108+ for ( let i = 0 ; i < moves . length ; i += 2 ) {
109+ tokens . push ( `${ Math . floor ( i / 2 ) + 1 } . ${ moves [ i ] } ` )
110+ if ( moves [ i + 1 ] ) {
111+ tokens . push ( moves [ i + 1 ] )
112+ }
113+ }
114+
115+ return tokens . join ( ' ' ) . trim ( )
116+ }
117+
118+ const normalizeCustomPgnForBackendStore = ( pgn : string ) : string => {
119+ const trimmed = pgn . trim ( )
120+ const candidates = Array . from (
121+ new Set ( [ trimmed , ensureBlankLineAfterPgnHeaders ( trimmed ) ] ) ,
122+ )
123+
124+ for ( const candidate of candidates ) {
125+ const chess = new Chess ( )
126+ if ( ! chess . loadPgn ( candidate , { sloppy : true } ) ) {
127+ continue
128+ }
129+
130+ const header = { ...chess . header ( ) }
131+
132+ const moveText = formatMoveHistoryAsPgn ( chess . history ( ) )
133+ const headerText = Object . entries ( header )
134+ . map ( ( [ key , value ] ) => `[${ key } "${ value } "]` )
135+ . join ( '\n' )
136+
137+ if ( ! headerText ) {
138+ return moveText
139+ }
140+
141+ return moveText ? `${ headerText } \n\n${ moveText } ` : headerText
82142 }
143+
144+ return ensureBlankLineAfterPgnHeaders ( trimmed )
145+ }
146+
147+ const extractPgnResultToken = ( pgn : string ) : string | undefined => {
148+ const headerMatch = pgn . match ( / \[ \s * R e s u l t \s + " ( 1 - 0 | 0 - 1 | 1 \/ 2 - 1 \/ 2 | \* ) " \s * \] / i)
149+ if ( headerMatch ) {
150+ return headerMatch [ 1 ]
151+ }
152+
153+ const tailMatch = pgn . match ( / (?: ^ | \s ) ( 1 - 0 | 0 - 1 | 1 \/ 2 - 1 \/ 2 | \* ) (?: \s * ) $ / )
154+ return tailMatch ?. [ 1 ]
83155}
84156
85157const getCustomPgnResultOverrides = ( ) : Record < string , string > => {
@@ -113,24 +185,17 @@ const setCustomPgnResultOverride = (gameId: string, result: string): void => {
113185 }
114186}
115187
116- const getCustomPgnResultOverride = ( gameId : string ) : string | undefined => {
117- const override = getCustomPgnResultOverrides ( ) [ gameId ]
118- return typeof override === 'string' ? override : undefined
119- }
120-
121- const resultTokenToWinner = (
122- result : string ,
123- ) : Termination [ 'winner' ] | undefined => {
188+ const resultTokenToWinner = ( result : string ) : 'white' | 'black' | 'none' => {
124189 if ( result === '1-0' ) return 'white'
125190 if ( result === '0-1' ) return 'black'
126- if ( result === '1/2-1/2' ) return 'none'
127- return undefined
191+ return 'none'
128192}
129193
130- const applyCustomResultOverride = (
194+ const applyCustomPgnResultOverride = (
195+ gameId : string ,
131196 game : AnalyzedGame ,
132- result ?: string ,
133197) : AnalyzedGame => {
198+ const result = getCustomPgnResultOverrides ( ) [ gameId ]
134199 if ( ! result || result === '*' ) return game
135200
136201 return {
@@ -240,17 +305,15 @@ const AnalysisPage: NextPage = () => {
240305 setCurrentMove ?: Dispatch < SetStateAction < number > > ,
241306 updateUrl = true ,
242307 ) => {
243- const game = await fetchAnalyzedMaiaGame ( id , type )
244- const gameWithOverrides =
245- type === 'custom'
246- ? applyCustomResultOverride ( game , getCustomPgnResultOverride ( id ) )
247- : game
308+ const rawGame = await fetchAnalyzedMaiaGame ( id , type )
309+ const game =
310+ type === 'custom' ? applyCustomPgnResultOverride ( id , rawGame ) : rawGame
248311
249312 if ( setCurrentMove ) setCurrentMove ( 0 )
250313
251- setAnalyzedGame ( { ...gameWithOverrides , type } )
314+ setAnalyzedGame ( { ...game , type } )
252315 setCurrentId ( [ id , type ] )
253- await loadGameAnalysisCache ( { ...gameWithOverrides , type } )
316+ await loadGameAnalysisCache ( { ...game , type } )
254317
255318 if ( updateUrl ) {
256319 router . push ( `/analysis/${ id } /${ type } ` , undefined , {
@@ -431,17 +494,19 @@ const Analysis: React.FC<Props> = ({
431494 ( type : 'fen' | 'pgn' , data : string , name ?: string ) => {
432495 ; ( async ( ) => {
433496 try {
434- const pgnPayload =
435- type === 'pgn' ? splitTrailingPgnResult ( data ) : undefined
497+ const pgnResult =
498+ type === 'pgn' ? extractPgnResultToken ( data ) : undefined
436499 const { game_id } = await storeCustomGame ( {
437500 name : name ,
438501 pgn :
439- type === 'pgn' ? pgnPayload ?. pgnWithoutResult || data : undefined ,
502+ type === 'pgn'
503+ ? normalizeCustomPgnForBackendStore ( data )
504+ : undefined ,
440505 fen : type === 'fen' ? data : undefined ,
441506 } )
442507
443- if ( pgnPayload ?. result && pgnPayload . result !== '*' ) {
444- setCustomPgnResultOverride ( game_id , pgnPayload . result )
508+ if ( pgnResult && pgnResult !== '*' ) {
509+ setCustomPgnResultOverride ( game_id , pgnResult )
445510 }
446511
447512 setShowCustomModal ( false )
@@ -456,7 +521,7 @@ const Analysis: React.FC<Props> = ({
456521 }
457522 } ) ( )
458523 } ,
459- [ ] ,
524+ [ router ] ,
460525 )
461526
462527 const handleLearnFromMistakes = useCallback ( ( ) => {
0 commit comments