feat: add KSP save file (.sfs) parser script
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
KSP SFS (Save File) Parser - Line-by-line stack-based, O(n).
|
||||
|
||||
Format:
|
||||
KEY <- block header
|
||||
{ <- open brace (same indent as header)
|
||||
... <- contents (deeper indent)
|
||||
} <- close brace
|
||||
key = value <- assignment
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Time conversion constants
|
||||
# ---------------------------------------------------------------------------
|
||||
# Game epoch: UT=0 corresponds to this real-world datetime.
|
||||
# All UT values in the save file are seconds elapsed since this moment.
|
||||
GAME_EPOCH = datetime(2051, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
|
||||
# RSS (Real Solar System) uses 86400 seconds per Earth day for display.
|
||||
SECONDS_PER_DAY = 86400
|
||||
SECONDS_PER_YEAR = 365.25 * SECONDS_PER_DAY
|
||||
|
||||
|
||||
def ut_to_datetime(ut_seconds: float | int) -> datetime:
|
||||
"""Convert a KSP UT seconds value to an absolute datetime."""
|
||||
if ut_seconds is None:
|
||||
return None
|
||||
return GAME_EPOCH + timedelta(seconds=float(ut_seconds))
|
||||
|
||||
|
||||
def ut_to_datestr(ut_seconds: float | int) -> str:
|
||||
"""Format UT seconds as human-readable date string."""
|
||||
if ut_seconds is None:
|
||||
return "N/A"
|
||||
dt = ut_to_datetime(ut_seconds)
|
||||
return dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def seconds_to_duration(seconds: float | int) -> str:
|
||||
"""Format seconds into a human-readable duration (years, days, hours, minutes)."""
|
||||
if seconds is None:
|
||||
return "N/A"
|
||||
total = float(seconds)
|
||||
if total < 60:
|
||||
return f"{total:.0f}s"
|
||||
|
||||
minutes = total / 60
|
||||
if total < 3600:
|
||||
return f"{minutes:.0f}m"
|
||||
|
||||
hours = total / 3600
|
||||
if total < SECONDS_PER_DAY:
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
days = int(total // SECONDS_PER_DAY)
|
||||
remainder = total % SECONDS_PER_DAY
|
||||
hours_part = remainder / 3600
|
||||
years = days // 365
|
||||
remaining_days = days % 365
|
||||
|
||||
if years > 0:
|
||||
if remaining_days > 0:
|
||||
return f"{years}y {remaining_days}d {hours_part:.0f}h"
|
||||
else:
|
||||
return f"{years}y {hours_part:.0f}h"
|
||||
else:
|
||||
return f"{days}d {hours_part:.0f}h"
|
||||
|
||||
|
||||
def parse_sfs_fast(filepath: str) -> dict:
|
||||
root = {}
|
||||
# Stack frames: (name, container_dict)
|
||||
stack: list[tuple[str, dict]] = []
|
||||
pending_key: str | None = None # Key seen on previous line (awaiting '{')
|
||||
pending_depth: int = -1
|
||||
|
||||
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Count leading tabs = depth
|
||||
depth = 0
|
||||
for ch in stripped:
|
||||
if ch == "\t":
|
||||
depth += 1
|
||||
else:
|
||||
break
|
||||
content = stripped[depth:]
|
||||
|
||||
if not content:
|
||||
continue
|
||||
|
||||
if content == "{":
|
||||
# Open brace: push the pending key as a new container
|
||||
if pending_key is not None and pending_depth == depth:
|
||||
new_container = {}
|
||||
parent = stack[-1][1] if stack else root
|
||||
_insert_into(parent, pending_key, new_container)
|
||||
stack.append((pending_key, new_container))
|
||||
pending_key = None
|
||||
continue
|
||||
|
||||
if content == "}":
|
||||
# Close brace: pop one level
|
||||
while stack and stack[-1][1] is not None:
|
||||
# Pop all frames at or deeper than this depth
|
||||
if len(stack) <= depth:
|
||||
break
|
||||
stack.pop()
|
||||
pending_key = None
|
||||
continue
|
||||
|
||||
# Check if this is an assignment: key = value
|
||||
if " = " in content:
|
||||
parts = content.split(" = ", 1)
|
||||
key = parts[0].strip()
|
||||
val_str = parts[1].strip()
|
||||
val = _parse_value(val_str)
|
||||
parent = stack[-1][1] if stack else root
|
||||
_insert_into(parent, key, val)
|
||||
pending_key = None
|
||||
continue
|
||||
|
||||
# Otherwise: it's a potential block header
|
||||
# Store it; next line will tell us if it's followed by '{'
|
||||
pending_key = content.strip()
|
||||
pending_depth = depth
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def _parse_value(val_str: str) -> Any:
|
||||
val_str = val_str.rstrip(",")
|
||||
if val_str == "True":
|
||||
return True
|
||||
if val_str == "False":
|
||||
return False
|
||||
if val_str == "None":
|
||||
return None
|
||||
|
||||
if "," in val_str:
|
||||
parts = [p.strip() for p in val_str.split(",")]
|
||||
result = []
|
||||
for p in parts:
|
||||
try:
|
||||
result.append(int(p))
|
||||
except ValueError:
|
||||
try:
|
||||
result.append(float(p))
|
||||
except ValueError:
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
try:
|
||||
return int(val_str)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(val_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return val_str
|
||||
|
||||
|
||||
def _insert_into(parent: dict, key: str, val: Any) -> None:
|
||||
"""Insert key=val into parent, converting to list on duplicate."""
|
||||
if key in parent:
|
||||
existing = parent[key]
|
||||
if isinstance(existing, list):
|
||||
existing.append(val)
|
||||
else:
|
||||
parent[key] = [existing, val]
|
||||
else:
|
||||
parent[key] = val
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_list(node: dict, key: str) -> list:
|
||||
val = node.get(key, [])
|
||||
if val is None:
|
||||
return []
|
||||
if isinstance(val, list):
|
||||
return val
|
||||
return [val]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extractors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_vessels(flightstate: dict) -> list[dict]:
|
||||
vessels = _get_list(flightstate, "VESSEL")
|
||||
results = []
|
||||
for v in vessels:
|
||||
parts = _get_list(v, "PART")
|
||||
orbit = v.get("ORBIT", {})
|
||||
|
||||
crew = []
|
||||
resources = {}
|
||||
for part in parts:
|
||||
modules = _get_list(part, "MODULE")
|
||||
for mod in modules:
|
||||
if mod.get("name") == "ModuleCommand":
|
||||
crew_raw = mod.get("crew", [])
|
||||
if isinstance(crew_raw, str):
|
||||
crew.append(crew_raw)
|
||||
elif isinstance(crew_raw, list):
|
||||
crew.extend([c for c in crew_raw if isinstance(c, str)])
|
||||
for res in _get_list(mod, "RESOURCE"):
|
||||
rname = res.get("name", "?")
|
||||
amt = float(res.get("amount", 0))
|
||||
max_amt = float(res.get("maxAmount", 0))
|
||||
if rname not in resources:
|
||||
resources[rname] = {"amount": 0.0, "maxAmount": 0.0}
|
||||
resources[rname]["amount"] += amt
|
||||
resources[rname]["maxAmount"] += max_amt
|
||||
|
||||
results.append({
|
||||
"name": v.get("name"),
|
||||
"type": v.get("type"),
|
||||
"sit": v.get("sit"),
|
||||
"landed": v.get("landed"),
|
||||
"lat": v.get("lat"),
|
||||
"lon": v.get("lon"),
|
||||
"alt": v.get("alt"),
|
||||
"met": v.get("met"),
|
||||
"met_str": seconds_to_duration(v.get("met")),
|
||||
"lct": v.get("lct"),
|
||||
"lct_str": ut_to_datestr(v.get("lct")), # launch date
|
||||
"lastUT": v.get("lastUT"),
|
||||
"lastUT_str": ut_to_datestr(v.get("lastUT")), # last contact
|
||||
"orbit_body": orbit.get("IDENT"),
|
||||
"orbit_sma": orbit.get("SMA"),
|
||||
"orbit_ecc": orbit.get("ECC"),
|
||||
"orbit_inc": orbit.get("INC"),
|
||||
"parts_count": len(parts),
|
||||
"crew": crew,
|
||||
"crew_count": len(crew),
|
||||
"resources": resources,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def extract_kerbals(roster: dict) -> list[dict]:
|
||||
result = []
|
||||
for k in _get_list(roster, "KERBAL"):
|
||||
career_log = k.get("CAREER_LOG", {})
|
||||
# flight count is stored as integer in "flight" key
|
||||
flight_count = career_log.get("flight", 0)
|
||||
if isinstance(flight_count, list):
|
||||
flight_count = len(flight_count)
|
||||
elif not isinstance(flight_count, int):
|
||||
flight_count = 0
|
||||
|
||||
result.append({
|
||||
"name": k.get("name"),
|
||||
"gender": k.get("gender"),
|
||||
"type": k.get("type"),
|
||||
"trait": k.get("trait"),
|
||||
"state": k.get("state"),
|
||||
"veteran": k.get("veteran"),
|
||||
"hero": k.get("hero"),
|
||||
"badS": k.get("badS"),
|
||||
"brave": k.get("brave"),
|
||||
"dumb": k.get("dumb"),
|
||||
"tour": k.get("tour"),
|
||||
"inactive": k.get("inactive"),
|
||||
"extraXP": k.get("extraXP", 0),
|
||||
"flights": flight_count,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def extract_contracts(game: dict) -> list[dict]:
|
||||
for sc in _get_list(game, "SCENARIO"):
|
||||
if sc.get("name") == "ContractSystem":
|
||||
result = []
|
||||
contracts_block = sc.get("CONTRACTS", {})
|
||||
for c in _get_list(contracts_block, "CONTRACT"):
|
||||
result.append({
|
||||
"guid": c.get("guid"),
|
||||
"type": c.get("type"),
|
||||
"prestige": c.get("prestige"),
|
||||
"state": c.get("state"),
|
||||
"agent": c.get("agent"),
|
||||
"title": c.get("title"),
|
||||
"targetBody": c.get("targetBody"),
|
||||
"deadlineType": c.get("deadlineType"),
|
||||
"expiryType": c.get("expiryType"),
|
||||
})
|
||||
return result
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kerbal Experience / Achievement Extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Achievement tiers ordered by prestige (higher = more XP)
|
||||
ACHIEVEMENT_TIERS = {
|
||||
"PlantFlag": 5,
|
||||
"Land": 4,
|
||||
"Suborbit": 3,
|
||||
"Orbit": 3,
|
||||
"ExitVessel": 2, # EVA
|
||||
"Flyby": 2,
|
||||
"Escape": 1,
|
||||
"BoardVessel": 1,
|
||||
"Flight": 1,
|
||||
"Recover": 0,
|
||||
"Die": -1,
|
||||
}
|
||||
|
||||
ACHIEVEMENT_LABELS = {
|
||||
"PlantFlag": "Flag",
|
||||
"Land": "Land",
|
||||
"Suborbit": "SubOrbit",
|
||||
"Orbit": "Orbit",
|
||||
"ExitVessel": "EVA",
|
||||
"Flyby": "Flyby",
|
||||
"Escape": "Escape",
|
||||
"BoardVessel": "Board",
|
||||
"Flight": "Flight",
|
||||
}
|
||||
|
||||
|
||||
def extract_achievements(kerbal: dict) -> dict:
|
||||
"""
|
||||
Extract unique achievements from a kerbal's CAREER_LOG.
|
||||
Returns a dict with:
|
||||
- total_flights: int
|
||||
- bodies_visited: set of body names
|
||||
- achievements: dict[body_name, set of achievement types]
|
||||
- highest_per_body: dict[body_name, str] - highest achievement per body
|
||||
- eva_bodies: set of bodies where EVA was performed
|
||||
- flags_planted: set of bodies where flag was planted
|
||||
"""
|
||||
career_log = kerbal.get("CAREER_LOG", {})
|
||||
flight_count = career_log.get("flight", 0)
|
||||
if isinstance(flight_count, list):
|
||||
flight_count = len(flight_count)
|
||||
if not isinstance(flight_count, (int, float)):
|
||||
flight_count = 0
|
||||
flight_count = int(flight_count)
|
||||
|
||||
achievements: dict[str, set] = defaultdict(set)
|
||||
all_bodies: set[str] = set()
|
||||
|
||||
for key in career_log:
|
||||
if not str(key).isdigit():
|
||||
continue
|
||||
flight_data = career_log[key]
|
||||
if not isinstance(flight_data, list):
|
||||
continue
|
||||
for event in flight_data:
|
||||
if isinstance(event, list) and len(event) == 2:
|
||||
achievement, body = event
|
||||
if achievement in ACHIEVEMENT_TIERS:
|
||||
achievements[str(body)].add(str(achievement))
|
||||
all_bodies.add(str(body))
|
||||
elif isinstance(event, str):
|
||||
# Single string like "Recover" or "Die"
|
||||
pass
|
||||
|
||||
# Determine highest achievement per body
|
||||
highest_per_body = {}
|
||||
for body, ach_set in achievements.items():
|
||||
best_tier = -1
|
||||
best_name = None
|
||||
for ach in ach_set:
|
||||
tier = ACHIEVEMENT_TIERS.get(ach, 0)
|
||||
if tier > best_tier:
|
||||
best_tier = tier
|
||||
best_name = ach
|
||||
highest_per_body[str(body)] = best_name
|
||||
|
||||
# Also check FLIGHT_LOG for ongoing flight
|
||||
flight_log = kerbal.get("FLIGHT_LOG", {})
|
||||
fl_flight_count = flight_log.get("flight", 0)
|
||||
if isinstance(fl_flight_count, (int, float)) and fl_flight_count > 0:
|
||||
for key in flight_log:
|
||||
if str(key).isdigit():
|
||||
fl_data = flight_log[key]
|
||||
if isinstance(fl_data, list):
|
||||
for event in fl_data:
|
||||
if isinstance(event, list) and len(event) == 2:
|
||||
achievement, body = event
|
||||
if achievement in ACHIEVEMENT_TIERS:
|
||||
achievements[str(body)].add(str(achievement))
|
||||
all_bodies.add(str(body))
|
||||
ach = str(achievement)
|
||||
tier = ACHIEVEMENT_TIERS.get(ach, 0)
|
||||
prev = highest_per_body.get(str(body))
|
||||
prev_tier = ACHIEVEMENT_TIERS.get(prev, -1) if prev else -1
|
||||
if tier > prev_tier:
|
||||
highest_per_body[str(body)] = ach
|
||||
|
||||
return {
|
||||
"total_flights": flight_count,
|
||||
"bodies_visited": sorted(all_bodies),
|
||||
"body_count": len(all_bodies),
|
||||
"achievements": {b: sorted(a) for b, a in achievements.items()},
|
||||
"highest_per_body": highest_per_body,
|
||||
"flags_planted": [b for b, ach in highest_per_body.items() if ach == "PlantFlag"],
|
||||
"eva_bodies": [b for b, a in achievements.items() if "ExitVessel" in a],
|
||||
}
|
||||
|
||||
|
||||
def format_achievement_line(ach_data: dict) -> str:
|
||||
"""Format a kerbal's achievements as a single-line summary."""
|
||||
flags = ach_data.get("flags_planted", [])
|
||||
eva_bodies = ach_data.get("eva_bodies", [])
|
||||
highest = ach_data.get("highest_per_body", {})
|
||||
|
||||
# Build a compact summary: body → best achievement
|
||||
parts = []
|
||||
for body in ach_data.get("bodies_visited", []):
|
||||
ach = highest.get(body, "?")
|
||||
label = ACHIEVEMENT_LABELS.get(ach, ach)
|
||||
parts.append(f"{body}:{label}")
|
||||
|
||||
summary = ", ".join(parts) if parts else "(no achievements)"
|
||||
|
||||
# Mark flags and EVAs
|
||||
extras = []
|
||||
if flags:
|
||||
extras.append(f"{len(flags)} flags")
|
||||
if eva_bodies:
|
||||
extras.append(f"EVA at {len(eva_bodies)} bodies")
|
||||
|
||||
return f"[{ach_data['total_flights']} flights, {ach_data['body_count']} bodies] {summary}" + (
|
||||
" | " + ", ".join(extras) if extras else ""
|
||||
)
|
||||
|
||||
|
||||
def rank_kerbals_by_experience(kerbals_achievements: list[dict]) -> list[dict]:
|
||||
"""Sort kerbals by a composite experience score."""
|
||||
scored = []
|
||||
for entry in kerbals_achievements:
|
||||
ach = entry["achievements_data"]
|
||||
# Score: sum of highest tier per body
|
||||
score = 0
|
||||
for body, best_ach in ach["highest_per_body"].items():
|
||||
score += ACHIEVEMENT_TIERS.get(best_ach, 0)
|
||||
# Bonus for variety
|
||||
score += ach["body_count"] * 0.5
|
||||
# Bonus for flags
|
||||
score += len(ach["flags_planted"]) * 2
|
||||
scored.append((score, entry))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
return [entry for _, entry in scored]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_report(tree: dict) -> None:
|
||||
game = tree.get("GAME", tree)
|
||||
print("=" * 72)
|
||||
print(" KSP SAVE FILE ANALYSIS")
|
||||
print("=" * 72)
|
||||
|
||||
print(f"\n Title: {game.get('Title')}")
|
||||
print(f" Version: {game.get('version')}")
|
||||
print(f" Mode: {game.get('Mode')}")
|
||||
print(f" Next Launch: #{game.get('launchID')}")
|
||||
|
||||
fs = game.get("FLIGHTSTATE", {})
|
||||
ut = fs.get("UT", 0)
|
||||
if ut:
|
||||
dt = ut_to_datetime(ut)
|
||||
ut_str = f"{dt.strftime('%Y-%m-%d %H:%M:%S UTC')} (Epoch: 2051-01-01, +{seconds_to_duration(ut)})"
|
||||
else:
|
||||
ut_str = "N/A"
|
||||
|
||||
print(f"\n--- FLIGHTSTATE ---")
|
||||
print(f" Current Time: {ut_str}")
|
||||
print(f" UT Seconds: {ut:,.0f}")
|
||||
|
||||
vessels = extract_vessels(fs)
|
||||
print(f" Total Vessels: {len(vessels)}")
|
||||
|
||||
# Vessels by type and situation
|
||||
by_type = {}
|
||||
by_sit = {}
|
||||
for v in vessels:
|
||||
t = v["type"] or "?"
|
||||
s = v["sit"] or "?"
|
||||
by_type[t] = by_type.get(t, 0) + 1
|
||||
by_sit[s] = by_sit.get(s, 0) + 1
|
||||
print(" By Type:")
|
||||
for t, c in sorted(by_type.items(), key=lambda x: -x[1]):
|
||||
print(f" {t:20s}: {c:4d}")
|
||||
print(" By Situation:")
|
||||
for s, c in sorted(by_sit.items(), key=lambda x: -x[1]):
|
||||
print(f" {s:20s}: {c:4d}")
|
||||
|
||||
# Roster
|
||||
roster = game.get("ROSTER", {})
|
||||
kerbals = extract_kerbals(roster)
|
||||
print(f"\n--- ROSTER: {len(kerbals)} Kerbals ---")
|
||||
by_state = {}
|
||||
by_trait = {}
|
||||
for k in kerbals:
|
||||
by_state[k["state"] or "?"] = by_state.get(k["state"] or "?", 0) + 1
|
||||
by_trait[k["trait"] or "?"] = by_trait.get(k["trait"] or "?", 0) + 1
|
||||
print(" By State:")
|
||||
for s, c in sorted(by_state.items(), key=lambda x: -x[1]):
|
||||
print(f" {s:15s}: {c:4d}")
|
||||
print(" By Trait:")
|
||||
for t, c in sorted(by_trait.items(), key=lambda x: -x[1]):
|
||||
print(f" {t:15s}: {c:4d}")
|
||||
|
||||
# Vessels detail
|
||||
print(f"\n--- VESSELS ({len(vessels)}) ---")
|
||||
print(f" {'Name':32s} {'Type':10s} {'Sit':7s} {'Body':8s} {'Parts':>5s} {'Crew':>4s} {'Launched':16s} {'Mission Time'}")
|
||||
print(f" {'-'*32} {'-'*10} {'-'*7} {'-'*8} {'-'*5} {'-'*4} {'-'*16} {'-'*15}")
|
||||
for v in vessels:
|
||||
lct = v.get('lct_str', '') or 'N/A'
|
||||
met = v.get('met_str', '') or 'N/A'
|
||||
print(
|
||||
f" {v['name'] or '?':32.32s} {v['type'] or '?':10.10s} {v['sit'] or '?':7.7s} "
|
||||
f"{v['orbit_body'] or '?':8.8s} {v['parts_count']:5d} {v['crew_count']:4d} "
|
||||
f"{lct:16s} {met}"
|
||||
)
|
||||
|
||||
# Contracts
|
||||
contracts = extract_contracts(game)
|
||||
print(f"\n--- CONTRACTS ({len(contracts)}) ---")
|
||||
if contracts:
|
||||
for c in contracts:
|
||||
print(f" [{c['state']}] {c['title']} (by {c['agent']}, target: {c['targetBody']})")
|
||||
|
||||
print("\n" + "=" * 72)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
filepath = sys.argv[1] if len(sys.argv) > 1 else "user_input/persistent.sfs"
|
||||
if not os.path.exists(filepath):
|
||||
print(f"File not found: {filepath}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Parsing {filepath} ({os.path.getsize(filepath)/1024/1024:.1f} MB)...")
|
||||
tree = parse_sfs_fast(filepath)
|
||||
print("Parsing complete.\n")
|
||||
|
||||
print_report(tree)
|
||||
|
||||
flags = set(sys.argv[1:])
|
||||
|
||||
if "--json" in flags:
|
||||
outpath = filepath.replace(".sfs", "_parsed.json")
|
||||
print(f"\nSaving full JSON to {outpath}...")
|
||||
with open(outpath, "w", encoding="utf-8") as f:
|
||||
json.dump(tree, f, indent=2, default=str, ensure_ascii=False)
|
||||
print("Done.")
|
||||
|
||||
if "--vessels" in flags:
|
||||
vessels = extract_vessels(tree.get("GAME", tree).get("FLIGHTSTATE", {}))
|
||||
print(json.dumps(vessels, indent=2, default=str, ensure_ascii=False))
|
||||
|
||||
if "--kerbals" in flags:
|
||||
kerbals = extract_kerbals(tree.get("GAME", tree).get("ROSTER", {}))
|
||||
print(json.dumps(kerbals, indent=2, default=str, ensure_ascii=False))
|
||||
|
||||
if "--contracts" in flags:
|
||||
print(json.dumps(extract_contracts(tree.get("GAME", tree)), indent=2,
|
||||
default=str, ensure_ascii=False))
|
||||
|
||||
if "--assignments" in flags:
|
||||
vessels = extract_vessels(tree.get("GAME", tree).get("FLIGHTSTATE", {}))
|
||||
assignments = []
|
||||
for v in vessels:
|
||||
for c in v["crew"]:
|
||||
assignments.append({"kerbal": c, "vessel": v["name"]})
|
||||
print(json.dumps(assignments, indent=2, default=str, ensure_ascii=False))
|
||||
|
||||
if "--experience" in flags or "--xp" in flags:
|
||||
roster = tree.get("GAME", tree).get("ROSTER", {})
|
||||
kerbals = _get_list(roster, "KERBAL")
|
||||
results = []
|
||||
for k in kerbals:
|
||||
ek = {
|
||||
"name": k.get("name"),
|
||||
"trait": k.get("trait"),
|
||||
"type": k.get("type"),
|
||||
"state": k.get("state"),
|
||||
"veteran": k.get("veteran"),
|
||||
"hero": k.get("hero"),
|
||||
}
|
||||
achievements = extract_achievements(k)
|
||||
ek["achievements"] = achievements
|
||||
ek["summary"] = format_achievement_line(achievements)
|
||||
results.append(ek)
|
||||
|
||||
ranked = rank_kerbals_by_experience(
|
||||
[{"kerbal": r, "achievements_data": r["achievements"]} for r in results]
|
||||
)
|
||||
|
||||
# Print as table
|
||||
print(f"\n{'Rank':>4s} {'Name':28s} {'Trait':14s} {'State':10s} Experience Summary")
|
||||
print(f"{'='*4} {'='*28} {'='*14} {'='*10} {'='*80}")
|
||||
for i, entry in enumerate(ranked):
|
||||
ek = entry["kerbal"]
|
||||
ach = entry["achievements_data"]
|
||||
flags_count = len(ach["flags_planted"])
|
||||
eva_count = len(ach["eva_bodies"])
|
||||
|
||||
# Build compact achievement string
|
||||
body_parts = []
|
||||
for body in ach["bodies_visited"]:
|
||||
best = ach["highest_per_body"].get(body, "?")
|
||||
label = ACHIEVEMENT_LABELS.get(best, best)
|
||||
body_parts.append(f"{body}:{label}")
|
||||
|
||||
ach_str = ", ".join(body_parts) if body_parts else "(none)"
|
||||
extras = []
|
||||
if flags_count:
|
||||
extras.append(f"{flags_count} flag{'s' if flags_count > 1 else ''}")
|
||||
if eva_count:
|
||||
extras.append(f"EVAx{eva_count}")
|
||||
if extras:
|
||||
ach_str += " [" + ", ".join(extras) + "]"
|
||||
|
||||
print(
|
||||
f"{i+1:4d} {ek['name'][:28]:28s} {ek['trait'] or '?':14s} "
|
||||
f"{ek['state'] or '?':10s} "
|
||||
f"{'V' if ek['veteran'] else ' '} {'H' if ek['hero'] else ' '} "
|
||||
f"F{ach['total_flights']:3d} B{ach['body_count']:2d} | {ach_str}"
|
||||
)
|
||||
|
||||
# JSON output as well
|
||||
if "--json" in flags:
|
||||
print("\n" + json.dumps([e["kerbal"] for e in ranked], indent=2,
|
||||
default=str, ensure_ascii=False))
|
||||
Reference in New Issue
Block a user