33import re
44from datetime import datetime , timedelta , date
55from enum import Enum
6- from typing import List , Optional , Union
6+ from typing import List , Optional , Union , Literal , Annotated
77
8- from pydantic import model_validator , BaseModel , FilePath
8+ from pydantic import model_validator , BaseModel , FilePath , Field , Discriminator
99
1010
1111class CompletedRecordEntry (BaseModel , extra = "forbid" ):
1212 """
1313 Model for tracking when you completed the game (without anything more)
1414 """
1515
16+ entry_type : Literal ["completed" ] = Field (default = "completed" , frozen = True )
1617 date : Union [date , datetime ]
1718 description : Optional [str ] = None
1819 screenshot : Optional [FilePath ] = None
@@ -23,6 +24,9 @@ class CompletedAtDifficultyRecordEntry(CompletedRecordEntry):
2324 Model for tracking when you completed the game at a specific difficulty
2425 """
2526
27+ entry_type : Literal ["completed_at_difficulty" ] = Field (
28+ default = "completed_at_difficulty" , frozen = True
29+ )
2630 difficulty : str
2731
2832
@@ -31,6 +35,7 @@ class TimeRecordEntry(CompletedRecordEntry):
3135 Model for tracking when you completed the game in a record time
3236 """
3337
38+ entry_type : Literal ["time" ] = Field (default = "time" , frozen = True )
3439 time : timedelta
3540
3641
@@ -39,6 +44,7 @@ class ScoreRecordEntry(CompletedRecordEntry):
3944 Model for tracking when you completed the game with a record score
4045 """
4146
47+ entry_type : Literal ["score" ] = Field (default = "score" , frozen = True )
4248 score : float
4349
4450
@@ -62,11 +68,14 @@ class RecordType(BaseModel):
6268 type : RecordTypeOptions
6369 records : Optional [
6470 List [
65- Union [
66- CompletedRecordEntry ,
67- CompletedAtDifficultyRecordEntry ,
68- TimeRecordEntry ,
69- ScoreRecordEntry ,
71+ Annotated [
72+ Union [
73+ CompletedAtDifficultyRecordEntry ,
74+ TimeRecordEntry ,
75+ ScoreRecordEntry ,
76+ CompletedRecordEntry ,
77+ ],
78+ Discriminator ("entry_type" ),
7079 ]
7180 ]
7281 ] = None
@@ -129,6 +138,49 @@ class RecordData(BaseModel):
129138
130139 games : List [Game ]
131140
141+ @classmethod
142+ def _preprocess_json_data (cls , json_data ):
143+ """
144+ Preprocess JSON data to:
145+ 1. Normalize path separators (backslash to forward slash) for cross-platform compatibility
146+ 2. Add entry_type field to records based on RecordType.type for discriminated unions
147+ """
148+ if "games" not in json_data :
149+ return json_data
150+
151+ for game in json_data ["games" ]:
152+ if "record_types" not in game or game ["record_types" ] is None :
153+ continue
154+
155+ for record_type in game ["record_types" ]:
156+ if "records" not in record_type or not record_type ["records" ]:
157+ continue
158+
159+ # Determine the entry_type based on record_type.type
160+ record_type_value = record_type .get ("type" )
161+ if record_type_value == "completed" :
162+ entry_type = "completed"
163+ elif record_type_value == "completed_at_difficulty" :
164+ entry_type = "completed_at_difficulty"
165+ elif record_type_value in ["fastest_time" , "longest_time" ]:
166+ entry_type = "time"
167+ elif record_type_value in ["high_score" , "low_score" ]:
168+ entry_type = "score"
169+ else :
170+ continue
171+
172+ # Process each record
173+ for record in record_type ["records" ]:
174+ # Add entry_type if not present
175+ if "entry_type" not in record :
176+ record ["entry_type" ] = entry_type
177+
178+ # Normalize screenshot path separators
179+ if "screenshot" in record and record ["screenshot" ]:
180+ record ["screenshot" ] = record ["screenshot" ].replace ("\\ " , "/" )
181+
182+ return json_data
183+
132184 @classmethod
133185 def load (cls , filename ):
134186 """
@@ -149,19 +201,45 @@ def load(cls, filename):
149201 with open (current_file ) as fin :
150202 json_data = json .load (fin )
151203
204+ # Preprocess JSON data for cross-platform compatibility and discriminated unions
205+ json_data = cls ._preprocess_json_data (json_data )
206+
152207 record_data = RecordData (** json_data )
153208
154209 # Restore original directory
155210 os .chdir (old_wd )
156211
157212 return record_data
158213
214+ @classmethod
215+ def _normalize_paths_for_save (cls , data ):
216+ """
217+ Normalize all path separators to forward slashes (Unix-style) for cross-platform compatibility.
218+ This ensures that even on Windows, paths are saved with forward slashes.
219+ """
220+ if isinstance (data , dict ):
221+ result = {}
222+ for key , value in data .items ():
223+ if key == "screenshot" and isinstance (value , str ):
224+ # Normalize screenshot paths to Unix-style
225+ result [key ] = value .replace ("\\ " , "/" )
226+ else :
227+ result [key ] = cls ._normalize_paths_for_save (value )
228+ return result
229+ elif isinstance (data , list ):
230+ return [cls ._normalize_paths_for_save (item ) for item in data ]
231+ else :
232+ return data
233+
159234 def save (self , filename ):
160235 """
161- Save records to a JSON file.
236+ Save records to a JSON file with Unix-style path separators .
162237 """
238+ data = self .model_dump ()
239+ # Normalize all paths to Unix-style (forward slashes)
240+ data = self ._normalize_paths_for_save (data )
163241 with open (filename , "w" ) as fout :
164- json .dump (self . model_dump () , fout , indent = 2 , default = str )
242+ json .dump (data , fout , indent = 2 , default = str )
165243
166244 @classmethod
167245 def add_game_to_file (cls , game_name , filename , interactive = True ):
0 commit comments