Skip to content

Commit dad537c

Browse files
fix: preserve custom PGN results in analysis UI
1 parent dcea107 commit dad537c

2 files changed

Lines changed: 157 additions & 6 deletions

File tree

src/components/Board/MovesContainer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ export const MovesContainer: React.FC<
360360
{termination.result}
361361
{', '}
362362
{termination.winner !== 'none'
363-
? `${termination.winner} is victorious`
363+
? `${termination.winner} wins`
364364
: 'draw'}
365365
</div>
366366
)}
@@ -486,7 +486,7 @@ export const MovesContainer: React.FC<
486486
{termination.result}
487487
{', '}
488488
{termination.winner !== 'none'
489-
? `${termination.winner} is victorious`
489+
? `${termination.winner} wins`
490490
: 'draw'}
491491
</div>
492492
)}

src/pages/analysis/[...id].tsx

Lines changed: 155 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,155 @@ import { MAIA_MODELS } from 'src/constants/common'
5757
import { applyEngineAnalysisData } from 'src/lib/analysis'
5858

5959
const EVAL_BAR_RANGE = 4
60+
const CUSTOM_PGN_RESULT_OVERRIDES_STORAGE_KEY =
61+
'maia_custom_pgn_result_overrides'
6062
const 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*Result\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

71211
const 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

Comments
 (0)