655 lines
22 KiB
Python
655 lines
22 KiB
Python
"""
|
|
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))
|