@@ -304,32 +304,63 @@ def parse_game_rules(reasoning: str) -> List[Dict[str, Any]]:
304304# ---------------------------------------------------------------------------
305305
306306
307+ def _split_named_list (raw : str ) -> List [str ]:
308+ """Split a comma/semicolon/bullet list of named system terms into clean items."""
309+ cleaned = raw .strip ().strip ("[]" )
310+ cleaned = re .sub (r"\([^)]*\)" , "" , cleaned )
311+ cleaned = cleaned .replace ("•" , "," )
312+ items = [part .strip (" -\t " ) for part in re .split (r",|;" , cleaned )]
313+ return [item [:100 ] for item in items if item and item .lower () not in {"none" , "n/a" , "na" }]
314+
315+
307316def parse_character_sheet (reasoning : str ) -> Dict [str , Any ]:
308- """
309- Parse DSPy character sheet output lines.
310- Format:
311- ATTR | name | abbrev | min | max | default | modifier_formula
312- SKILL | name | linked_attribute | description
313- RESOURCE | name | abbrev | min | recovers_on | depleted_effect
314- MECHANIC | type | formula | success_type
315- """
317+ """Parse structured or semi-structured system-schema output from the analyzer prompt."""
316318 attributes : List [Dict [str , Any ]] = []
317319 skills : List [Dict [str , Any ]] = []
318320 resources : List [Dict [str , Any ]] = []
321+ powers : List [Dict [str , Any ]] = []
322+ subsystems : List [Dict [str , Any ]] = []
319323 core_mechanic : Dict [str , Any ] = {}
320324
321- for line in reasoning .splitlines ():
322- line = line .strip ()
325+ attr_aliases = {"ATTR" , "ATTRIBUTE" , "ATTRIBUTES" , "ABILITY" , "ABILITIES" , "STAT" , "STATS" , "TRAIT" , "TRAITS" }
326+ skill_aliases = {"SKILL" , "SKILLS" , "KNACK" , "KNACKS" , "APPROACH" , "APPROACHES" }
327+ resource_aliases = {"RESOURCE" , "RESOURCES" , "TRACK" , "TRACKS" }
328+ power_aliases = {"POWER" , "POWERS" , "DISCIPLINE" , "DISCIPLINES" , "ARCANA" , "SCHOOL" , "SCHOOLS" , "EDGE" , "EDGES" , "FEAT" , "FEATS" , "MERIT" , "MERITS" , "ADVANTAGE" , "ADVANTAGES" }
329+ subsystem_aliases = {"SUBSYSTEM" , "SUBSYSTEMS" , "MODULE" , "MODULES" , "MODE" , "MODES" }
330+ mechanic_aliases = {"MECHANIC" , "MECHANICS" , "CORE_MECHANIC" , "SYSTEM" }
331+
332+ for raw_line in reasoning .splitlines ():
333+ line = raw_line .strip ()
323334 if not line :
324335 continue
325- parts = [p .strip () for p in line .split ("|" )]
336+
337+ # Accept human-readable heading lines like "Attributes: Brawn, Finesse, Wits"
338+ if "|" not in line and ":" in line :
339+ heading , body = line .split (":" , 1 )
340+ heading_key = heading .strip ().upper ().replace (" " , "_" )
341+ parts = [heading_key , body .strip ()]
342+ else :
343+ parts = [p .strip () for p in line .split ("|" )]
344+
326345 keyword = parts [0 ].upper () if parts else ""
327346
328- if keyword == "ATTR" and len (parts ) >= 3 :
347+ if keyword in attr_aliases and len (parts ) >= 2 :
329348 try :
349+ if len (parts ) == 2 or (len (parts ) <= 3 and any (sep in parts [1 ] for sep in ["," , ";" ])):
350+ for name in _split_named_list (" " .join (parts [1 :])):
351+ attributes .append ({
352+ "name" : name ,
353+ "abbreviation" : name [:10 ].upper (),
354+ "min_value" : 1 ,
355+ "max_value" : 20 ,
356+ "default_value" : 10 ,
357+ "modifier_formula" : None ,
358+ })
359+ continue
360+
330361 attributes .append ({
331362 "name" : parts [1 ][:100 ],
332- "abbreviation" : parts [2 ][:10 ] if len (parts ) > 2 else parts [1 ][:10 ],
363+ "abbreviation" : parts [2 ][:10 ] if len (parts ) > 2 and parts [ 2 ] else parts [1 ][:10 ]. upper () ,
333364 "min_value" : int (parts [3 ]) if len (parts ) > 3 and parts [3 ].lstrip ("-" ).isdigit () else 1 ,
334365 "max_value" : int (parts [4 ]) if len (parts ) > 4 and parts [4 ].lstrip ("-" ).isdigit () else 20 ,
335366 "default_value" : int (parts [5 ]) if len (parts ) > 5 and parts [5 ].lstrip ("-" ).isdigit () else 10 ,
@@ -338,33 +369,84 @@ def parse_character_sheet(reasoning: str) -> Dict[str, Any]:
338369 except (ValueError , IndexError ):
339370 continue
340371
341- elif keyword == "SKILL" and len (parts ) >= 2 :
372+ elif keyword in skill_aliases and len (parts ) >= 2 :
342373 try :
374+ if len (parts ) == 2 or (len (parts ) <= 3 and any (sep in parts [1 ] for sep in ["," , ";" ])):
375+ for name in _split_named_list (" " .join (parts [1 :])):
376+ skills .append ({
377+ "name" : name ,
378+ "linked_attribute" : None ,
379+ "description" : None ,
380+ })
381+ continue
382+
343383 skills .append ({
344384 "name" : parts [1 ][:100 ],
345385 "linked_attribute" : parts [2 ][:100 ] if len (parts ) > 2 and parts [2 ] else None ,
346- "description" : parts [3 ][:500 ] if len (parts ) > 3 else None ,
386+ "description" : parts [3 ][:500 ] if len (parts ) > 3 and parts [ 3 ] else None ,
347387 })
348388 except (ValueError , IndexError ):
349389 continue
350390
351- elif keyword == "RESOURCE" and len (parts ) >= 2 :
391+ elif keyword in resource_aliases and len (parts ) >= 2 :
352392 try :
393+ if len (parts ) == 2 or (len (parts ) <= 3 and any (sep in parts [1 ] for sep in ["," , ";" ])):
394+ for name in _split_named_list (" " .join (parts [1 :])):
395+ resources .append ({
396+ "name" : name ,
397+ "abbreviation" : name [:10 ].upper (),
398+ "min_value" : 0 ,
399+ "recovers_on" : None ,
400+ "depleted_effect" : None ,
401+ "calculation" : None ,
402+ })
403+ continue
404+
353405 resources .append ({
354406 "name" : parts [1 ][:100 ],
355- "abbreviation" : parts [2 ][:10 ] if len (parts ) > 2 and parts [2 ] else parts [1 ][:10 ],
407+ "abbreviation" : parts [2 ][:10 ] if len (parts ) > 2 and parts [2 ] else parts [1 ][:10 ]. upper () ,
356408 "min_value" : int (parts [3 ]) if len (parts ) > 3 and parts [3 ].lstrip ("-" ).isdigit () else 0 ,
357409 "recovers_on" : parts [4 ][:100 ] if len (parts ) > 4 and parts [4 ] else None ,
358410 "depleted_effect" : parts [5 ][:200 ] if len (parts ) > 5 and parts [5 ] else None ,
411+ "calculation" : parts [6 ][:200 ] if len (parts ) > 6 and parts [6 ] else None ,
412+ })
413+ except (ValueError , IndexError ):
414+ continue
415+
416+ elif keyword in power_aliases and len (parts ) >= 2 :
417+ try :
418+ if len (parts ) == 2 or (len (parts ) <= 3 and any (sep in parts [1 ] for sep in ["," , ";" ])):
419+ for name in _split_named_list (" " .join (parts [1 :])):
420+ powers .append ({
421+ "name" : name ,
422+ "category" : "power" ,
423+ "description" : None ,
424+ })
425+ continue
426+
427+ powers .append ({
428+ "name" : parts [1 ][:100 ],
429+ "category" : parts [2 ][:100 ] if len (parts ) > 2 and parts [2 ] else "power" ,
430+ "description" : parts [3 ][:500 ] if len (parts ) > 3 and parts [3 ] else None ,
431+ })
432+ except (ValueError , IndexError ):
433+ continue
434+
435+ elif keyword in subsystem_aliases and len (parts ) >= 2 :
436+ try :
437+ subsystems .append ({
438+ "name" : parts [1 ][:100 ],
439+ "scope" : parts [2 ][:100 ] if len (parts ) > 2 and parts [2 ] else "custom" ,
440+ "description" : parts [3 ][:500 ] if len (parts ) > 3 and parts [3 ] else None ,
359441 })
360442 except (ValueError , IndexError ):
361443 continue
362444
363- elif keyword == "MECHANIC" and len (parts ) >= 2 and not core_mechanic :
445+ elif keyword in mechanic_aliases and len (parts ) >= 2 and not core_mechanic :
364446 try :
365447 core_mechanic = {
366448 "type" : parts [1 ].lower () if len (parts ) > 1 else "d20" ,
367- "formula" : parts [2 ][:200 ] if len (parts ) > 2 and parts [2 ] else "Roll and compare" ,
449+ "formula" : parts [2 ][:200 ] if len (parts ) > 2 and parts [2 ] else ( parts [ 1 ][: 200 ] if len ( parts ) == 2 else "Roll and compare" ) ,
368450 "success_type" : parts [3 ].lower () if len (parts ) > 3 and parts [3 ] else "meet_or_beat" ,
369451 }
370452 except (ValueError , IndexError ):
@@ -374,6 +456,8 @@ def parse_character_sheet(reasoning: str) -> Dict[str, Any]:
374456 "attributes" : attributes ,
375457 "skills" : skills ,
376458 "resources" : resources ,
459+ "powers" : powers ,
460+ "subsystems" : subsystems ,
377461 "core_mechanic" : core_mechanic ,
378462 }
379463
0 commit comments