#!/usr/bin/env python3 """ Extract game knowledge strings from AIAnalyze.cs, expand [MOD:] tags, and write mod-specific knowledge files. Usage: python expand_knowledge.py [--cs-path PATH] Outputs: knowledge_default.md — base game (vanilla) knowledge knowledge_corona.md — Corona mod knowledge """ import re import sys import os from pathlib import Path # ── 1. Extract @"" verbatim strings from C# source ───────────────────────── def extract_verbatim_string(text: str, start: int) -> tuple[str, int]: """ Extract a C# @"" verbatim string starting after '@"'. Returns (content, end_position). Handles "" as escaped double-quote. """ chars = [] i = start while i < len(text): c = text[i] if c == '"': # Escaped quote "" → one literal " if i + 1 < len(text) and text[i + 1] == '"': chars.append('"') i += 2 continue # End of verbatim string i += 1 break chars.append(c) i += 1 return ''.join(chars), i def find_strings(cs_path: str) -> dict[str, str]: """Find all known knowledge string variables in the .cs file and return {variable_name: content}.""" with open(cs_path, 'r', encoding='utf-8') as f: source = f.read() known_vars = [ 'generalDescriptions', 'alliedDescriptions', 'celestialDescriptions', 'infinityIsleVanilla', 'infinityIsleCorona', ] results = {} for var in known_vars: # Look for: var {name} = @" pattern = f'var {var} = @"' idx = source.find(pattern) if idx == -1: print(f'[WARN] Could not find "{var}" in source') continue content, end = extract_verbatim_string(source, idx + len(pattern)) # content still has the leading newline from @"\n... results[var] = content print(f'[OK] {var}: {len(content)} chars') return results # ── 2. [MOD:] tag expansion ──────────────────────────────────────────────── def expand_mod_text(text: str, mod_name: str) -> str: """Expand [MOD:...] and [MOD:NO:...] tags for the given mod. Mimics the logic in AIAnalyze.Process().""" lines = text.replace('\r', '').split('\n') result_lines = [] skip_next = False for line in lines: if skip_next: skip_next = False continue stripped = line.strip() # Line-level [MOD:xxx] (must be at start of line, possibly with leading spaces) # Check for [MOD:NO:xxx] first no_match = re.match(r'^(\s*)\[MOD:NO:([^\]]+)\]$', line) if no_match: indent, denied_mod = no_match.groups() if denied_mod.lower() == mod_name.lower(): # [MOD:NO:corona] when mod is corona → skip content line skip_next = True # Either way, skip the tag line itself continue # Check for [MOD:xxx] mod_match = re.match(r'^(\s*)\[MOD:([^\]]+)\]$', line) if mod_match: indent, entry_mod = mod_match.groups() if entry_mod.lower() != mod_name.lower(): # This mod's content doesn't apply → skip content line skip_next = True # Skip the tag line itself continue # Inline tags: process [MOD:xxx]content[/MOD] and [MOD:NO:xxx]content[/MOD] # Multiple inline tags can appear on one line (e.g. line 662) processed = line while True: # Find next [MOD: or [MOD:NO: tag_match = re.search( r'\[MOD:(NO:)?([^\]]+)\](.*?)\[/MOD\]', processed, re.IGNORECASE ) if not tag_match: break is_no = tag_match.group(1) is not None entry_mod = tag_match.group(2) inner_text = tag_match.group(3) before = processed[:tag_match.start()] after = processed[tag_match.end():] include = False if is_no: # [MOD:NO:corona] → include if mod != corona if entry_mod.lower() != mod_name.lower(): include = True else: # [MOD:corona] → include if mod == corona if entry_mod.lower() == mod_name.lower(): include = True if include: processed = before + inner_text + after else: processed = before + after result_lines.append(processed) return '\n'.join(result_lines) # ── 3. Assemble per-mod knowledge ────────────────────────────────────────── def assemble_knowledge(strings: dict[str, str], mod_name: str) -> str: """Assemble the full knowledge text for a given mod.""" parts = [] # generalDescriptions — most is global rules, but has one inline [MOD:CORONA] tag # on the faction list line; process it to expand that tag. parts.append(expand_mod_text(strings.get('generalDescriptions', ''), mod_name).strip()) # alliedDescriptions allied = strings.get('alliedDescriptions', '') parts.append(expand_mod_text(allied, mod_name).strip()) # celestialDescriptions celestial = strings.get('celestialDescriptions', '') parts.append(expand_mod_text(celestial, mod_name).strip()) # Map description — mod-specific map_key = f'infinityIsle{mod_name.capitalize()}' if map_key in strings: parts.append(strings[map_key].strip()) else: # Fallback: default map default_map = strings.get('infinityIsleVanilla', '') parts.append(expand_mod_text(default_map, mod_name).strip()) return '\n\n\n'.join(p for p in parts if p) # ── 4. Main ──────────────────────────────────────────────────────────────── def main(): # Determine paths script_dir = Path(__file__).resolve().parent repo_root = script_dir.parent # tools/ is directly under repo root cs_path = repo_root / 'Utils' / 'AIAnalyze.cs' output_dir = repo_root # Override via CLI if '--cs-path' in sys.argv: idx = sys.argv.index('--cs-path') if idx + 1 < len(sys.argv): cs_path = Path(sys.argv[idx + 1]) print(f'Reading: {cs_path}') if not cs_path.exists(): print(f'[ERROR] File not found: {cs_path}') sys.exit(1) # Extract all strings strings = find_strings(str(cs_path)) if not strings: print('[ERROR] No strings extracted, aborting.') sys.exit(1) # Generate per-mod files for mod_name in ('default', 'corona'): knowledge = assemble_knowledge(strings, mod_name) out_path = output_dir / f'knowledge_{mod_name}.md' with open(out_path, 'w', encoding='utf-8') as f: f.write(knowledge) lines = knowledge.count('\n') + 1 print(f'[OK] {out_path.name}: {lines} lines, {len(knowledge)} chars') print('\nDone. Files written to:', output_dir) if __name__ == '__main__': main()