@@ -57,15 +57,155 @@ import { MAIA_MODELS } from 'src/constants/common'
5757import { applyEngineAnalysisData } from 'src/lib/analysis'
5858
5959const EVAL_BAR_RANGE = 4
60+ const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY =
61+ 'maia_custom_pgn_result_overrides'
6062const DEFAULT_STOCKFISH_EVAL_BAR = {
6163 hasEval : false ,
6264 pawns : 0 ,
6365 displayPawns : 0 ,
6466 label : '--' ,
6567}
6668
67- const stripTrailingPgnResultToken = ( pgn : string ) : string => {
68- return pgn . replace ( / (?: \s + ) ( 1 - 0 | 0 - 1 | 1 \/ 2 - 1 \/ 2 | \* ) \s * $ / , '' ) . trim ( )
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 ++
81+ }
82+
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
142+ }
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 ]
155+ }
156+
157+ const getCustomPgnResultOverrides = ( ) : Record < string , string > => {
158+ if ( typeof window === 'undefined' ) return { }
159+
160+ try {
161+ const raw = window . localStorage . getItem (
162+ CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY ,
163+ )
164+ if ( ! raw ) return { }
165+ const parsed = JSON . parse ( raw )
166+ return parsed && typeof parsed === 'object' ? parsed : { }
167+ } catch ( error ) {
168+ console . warn ( 'Failed to read custom PGN result overrides:' , error )
169+ return { }
170+ }
171+ }
172+
173+ const setCustomPgnResultOverride = ( gameId : string , result : string ) : void => {
174+ if ( typeof window === 'undefined' ) return
175+
176+ try {
177+ const overrides = getCustomPgnResultOverrides ( )
178+ overrides [ gameId ] = result
179+ window . localStorage . setItem (
180+ CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY ,
181+ JSON . stringify ( overrides ) ,
182+ )
183+ } catch ( error ) {
184+ console . warn ( 'Failed to store custom PGN result override:' , error )
185+ }
186+ }
187+
188+ const resultTokenToWinner = ( result : string ) : 'white' | 'black' | 'none' => {
189+ if ( result === '1-0' ) return 'white'
190+ if ( result === '0-1' ) return 'black'
191+ return 'none'
192+ }
193+
194+ const applyCustomPgnResultOverride = (
195+ gameId : string ,
196+ game : AnalyzedGame ,
197+ ) : AnalyzedGame => {
198+ const result = getCustomPgnResultOverrides ( ) [ gameId ]
199+ if ( ! result || result === '*' ) return game
200+
201+ return {
202+ ...game ,
203+ termination : {
204+ ...( game . termination || { } ) ,
205+ result,
206+ winner : resultTokenToWinner ( result ) ,
207+ } ,
208+ }
69209}
70210
71211const AnalysisPage : NextPage = ( ) => {
@@ -165,7 +305,9 @@ const AnalysisPage: NextPage = () => {
165305 setCurrentMove ?: Dispatch < SetStateAction < number > > ,
166306 updateUrl = true ,
167307 ) => {
168- const game = await fetchAnalyzedMaiaGame ( id , type )
308+ const rawGame = await fetchAnalyzedMaiaGame ( id , type )
309+ const game =
310+ type === 'custom' ? applyCustomPgnResultOverride ( id , rawGame ) : rawGame
169311
170312 if ( setCurrentMove ) setCurrentMove ( 0 )
171313
@@ -352,12 +494,21 @@ const Analysis: React.FC<Props> = ({
352494 ( type : 'fen' | 'pgn' , data : string , name ?: string ) => {
353495 ; ( async ( ) => {
354496 try {
497+ const pgnResult =
498+ type === 'pgn' ? extractPgnResultToken ( data ) : undefined
355499 const { game_id } = await storeCustomGame ( {
356500 name : name ,
357- pgn : type === 'pgn' ? stripTrailingPgnResultToken ( data ) : undefined ,
501+ pgn :
502+ type === 'pgn'
503+ ? normalizeCustomPgnForBackendStore ( data )
504+ : undefined ,
358505 fen : type === 'fen' ? data : undefined ,
359506 } )
360507
508+ if ( pgnResult && pgnResult !== '*' ) {
509+ setCustomPgnResultOverride ( game_id , pgnResult )
510+ }
511+
361512 setShowCustomModal ( false )
362513 router . push ( `/analysis/${ game_id } /custom` )
363514 } catch ( error ) {
0 commit comments