feat(docs): add wiki stub system for 300 star systems

Generated wiki stubs at docs/wiki/systems/{GJ-id}/index.md for all
300 systems. Each stub has YAML frontmatter (54 machine-readable
columns) + markdown body (5 authored text sections) + derived fields.

13 columns pre-filled from star-map.json (identity, gate infrastructure,
location, wave, earth_proximity). 55 columns await authoring.

Includes:
- tooling/wiki/generate-stubs.py — template renderer, idempotent
- tooling/wiki/extract-csv.py — wiki→CSV extractor with fill rate report
- docs/design/star-systems.csv — initial CSV at 19.3% completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 23:24:17 +01:00
co-authored by Claude Opus 4.6
parent 26276a59f4
commit 3c39d1a35a
303 changed files with 36453 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
extract-csv.py
Extracts all wiki system data into a single CSV file.
Reads: docs/wiki/systems/*/index.md (YAML frontmatter + markdown body sections)
Writes: docs/design/star-systems.csv
Body sections (## headers) are mapped to columns:
Supply Dependency -> supply_dependency
Faction Notes -> faction_notes
Silence Topic -> silence_topic
Narrative Hook -> narrative_hook
Calibration Note -> calibration_note
"""
import csv
import re
from pathlib import Path
WIKI_DIR = Path(__file__).parent.parent.parent / "docs/wiki/systems"
OUTPUT_CSV = Path(__file__).parent.parent.parent / "docs/design/star-systems.csv"
# CSV column order — matches systems-framework.md section order
COLUMNS = [
# I. Identity and Location
"system_id", "astronomical_id", "proper_name", "system_name", "star_type",
"geographic_sector", "geographic_band", "political_zone",
# II. Physical Character
"habitable_planet_count", "inhabited_planet_count", "asteroid_belt",
"gas_giant", "habitability_profile",
# III. Gate Infrastructure
"horizon_station", "aperture_count", "gate_connections", "gate_topology",
"span_gate_network",
# IV. Settlement History
"settlement_wave", "founding_motivation", "founding_culture_primary",
"founding_culture_secondary", "cultural_persistence", "historical_event",
"historical_event_age_years", "religious_status", "religious_generation_count",
# V. Economic Life
"economic_tier", "population", "economic_base_primary",
"economic_base_secondary", "distribution_index", "imprint_access",
"supply_dependency",
# VI. Governance
"governance_type", "dominant_faction", "primary_fault_line",
"secondary_fault_line",
# VII. Faction Presence
"assembly_presence", "commission_presence", "syndic_presence", "syndic_type",
"separatist_presence", "separatist_type", "institute_presence",
"guardians_presence", "faction_notes",
# VIII. Cultural Voice
"cultural_register", "cultural_register_secondary", "ambient_anxiety",
"local_pride", "atmospheric_tone", "atmospheric_tone_secondary",
"active_situation", "silence_threshold", "silence_topic",
# IX. Political Character
"earth_alignment", "earth_proximity", "earth_tension",
# X. Narrative Profile
"primary_archetype", "secondary_archetype", "narrative_notable",
"narrative_hook", "generation_priority",
# XI. Content Notes
"stability_index", "system_volatility", "cultural_corridor",
"calibration_note",
]
# Map markdown section headers to column names
BODY_SECTION_MAP = {
"supply dependency": "supply_dependency",
"faction notes": "faction_notes",
"silence topic": "silence_topic",
"narrative hook": "narrative_hook",
"calibration note": "calibration_note",
}
def parse_frontmatter(text: str) -> dict[str, str]:
"""Extract YAML frontmatter between --- delimiters, ignoring comments."""
match = re.match(r"^---\n(.*?\n)---", text, re.DOTALL)
if not match:
return {}
fm_text = match.group(1)
result = {}
for line in fm_text.split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
# Strip quotes
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
elif value.startswith("'") and value.endswith("'"):
value = value[1:-1]
# Convert YAML null to empty string
if value == "~" or value.lower() == "null" or value == "":
value = ""
result[key] = value
return result
def parse_body_sections(text: str) -> dict[str, str]:
"""Extract content from ## sections in the markdown body."""
# Find where frontmatter ends
match = re.match(r"^---\n.*?\n---\n", text, re.DOTALL)
if not match:
body = text
else:
body = text[match.end():]
sections: dict[str, str] = {}
current_key: str | None = None
current_lines: list[str] = []
for line in body.split("\n"):
if line.startswith("## "):
# Save previous section
if current_key is not None:
sections[current_key] = "\n".join(current_lines).strip()
header = line[3:].strip().lower()
current_key = BODY_SECTION_MAP.get(header)
current_lines = []
elif current_key is not None:
# Skip HTML comments and the Derived Fields section
if line.strip().startswith("<!--") and line.strip().endswith("-->"):
continue
current_lines.append(line)
# Save last section
if current_key is not None:
sections[current_key] = "\n".join(current_lines).strip()
return sections
def main():
wiki_dirs = sorted(WIKI_DIR.iterdir())
rows: list[dict[str, str]] = []
errors: list[str] = []
for d in wiki_dirs:
if not d.is_dir():
continue
index_file = d / "index.md"
if not index_file.exists():
errors.append(f"Missing index.md in {d.name}")
continue
text = index_file.read_text()
# Parse frontmatter
fm = parse_frontmatter(text)
# Parse body sections
body = parse_body_sections(text)
# Merge into row
row: dict[str, str] = {}
for col in COLUMNS:
if col in fm:
row[col] = fm[col]
elif col in body:
row[col] = body[col]
else:
row[col] = ""
rows.append(row)
# Sort by system_id
rows.sort(key=lambda r: r.get("system_id", ""))
# Write CSV
OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True)
with open(OUTPUT_CSV, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=COLUMNS)
writer.writeheader()
writer.writerows(rows)
# Summary
non_empty_counts = {col: 0 for col in COLUMNS}
for row in rows:
for col in COLUMNS:
if row.get(col, ""):
non_empty_counts[col] += 1
print(f"Extracted {len(rows)} systems to {OUTPUT_CSV}")
if errors:
print(f"\nErrors ({len(errors)}):")
for e in errors:
print(f" {e}")
# Completion stats
total_cells = len(rows) * len(COLUMNS)
filled_cells = sum(non_empty_counts.values())
print(f"\nCompletion: {filled_cells}/{total_cells} cells filled ({filled_cells/total_cells*100:.1f}%)")
print(f"\nColumn fill rates:")
for col in COLUMNS:
count = non_empty_counts[col]
pct = count / len(rows) * 100 if rows else 0
bar = "█" * int(pct / 5) + "░" * (20 - int(pct / 5))
status = "✓" if pct == 100 else " "
print(f" {status} {col:35s} {count:3d}/{len(rows)} {bar} {pct:.0f}%")
if __name__ == "__main__":
main()
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
generate-stubs.py
Generates wiki stub markdown files for all 300 star systems from star-map.json.
Each system gets: docs/wiki/systems/{gj_dir}/index.md
where {gj_dir} is the astronomical_id with spaces replaced by hyphens.
Pre-fills all fields available from star-map.json; marks the rest as null (~).
Safe to re-run: skips files that already exist.
"""
import json
import re
from pathlib import Path
STAR_MAP_PATH = Path(__file__).parent.parent.parent / "docs/design/star-map.json"
WIKI_DIR = Path(__file__).parent.parent.parent / "docs/wiki/systems"
def gj_to_dirname(astronomical_id: str) -> str:
"""Convert 'GJ 71' to 'GJ-71', '2MASS J...' to '2MASS-J...'."""
return astronomical_id.replace(" ", "-")
def render_stub(node: dict, adjacent_ids: list[str]) -> str:
"""Render a wiki stub markdown file for a single system."""
sid = node["system_id"]
astro_id = node["astronomical_id"]
proper_name = node.get("proper_name")
system_name = proper_name if proper_name else node.get("system_name", f"System {sid}")
star_type = node.get("star_type", "~")
sector = node.get("geographic_sector", "~")
band = node.get("geographic_band", "~")
zone = node.get("political_zone", "~")
wave = node.get("settlement_wave", "~")
horizon = node.get("aperture_count", 0) > 0
aperture = node.get("aperture_count", 0)
connections = node.get("gate_connections", 0)
topology = node.get("gate_topology", "~")
earth_prox = node.get("earth_proximity", "~")
hop = node.get("hop_distance_from_gateway", "~")
# Format proper_name for YAML (null or quoted string)
proper_yaml = f'"{proper_name}"' if proper_name else "~"
# Adjacent systems list for derived section
adjacent_str = ", ".join(adjacent_ids) if adjacent_ids else "none"
# Approximate settlement age from wave
wave_ages = {
"wave_1": "~550 years",
"wave_2": "~400 years",
"wave_3": "~200 years",
"wave_4": "~70 years",
"wave_5": "~20 years",
"unsettled": "unsettled",
}
age_str = wave_ages.get(wave, "unknown")
# Compound zone display
compound = f"{sector} / {band} / {zone}"
return f"""---
# ============================================================
# {astro_id} — {system_name}
# Star Systems Wiki — The Settled Reach
# ============================================================
# Auto-generated stub. Pre-filled fields from star-map.json.
# Fields marked ~ require authoring.
# ============================================================
# I. Identity and Location
system_id: {sid}
astronomical_id: "{astro_id}"
proper_name: {proper_yaml}
system_name: "{system_name}"
star_type: {star_type}
geographic_sector: {sector}
geographic_band: {band}
political_zone: {zone}
# II. Physical Character
habitable_planet_count: ~
inhabited_planet_count: ~
asteroid_belt: ~
gas_giant: ~
habitability_profile: ~
# III. Gate Infrastructure
horizon_station: {str(horizon).lower()}
aperture_count: {aperture}
gate_connections: {connections}
gate_topology: {topology}
span_gate_network: ~
# IV. Settlement History
settlement_wave: {wave}
founding_motivation: ~
founding_culture_primary: ~
founding_culture_secondary: ~
cultural_persistence: ~
historical_event: ~
historical_event_age_years: ~
religious_status: ~
religious_generation_count: ~
# V. Economic Life
economic_tier: ~
population: ~
economic_base_primary: ~
economic_base_secondary: ~
distribution_index: ~
imprint_access: ~
# VI. Governance
governance_type: ~
dominant_faction: ~
primary_fault_line: ~
secondary_fault_line: ~
# VII. Faction Presence
assembly_presence: ~
commission_presence: ~
syndic_presence: ~
syndic_type: ~
separatist_presence: ~
separatist_type: ~
institute_presence: ~
guardians_presence: ~
# VIII. Cultural Voice
cultural_register: ~
cultural_register_secondary: ~
ambient_anxiety: ~
local_pride: ~
atmospheric_tone: ~
atmospheric_tone_secondary: ~
active_situation: ~
silence_threshold: ~
# IX. Political Character
earth_alignment: ~
earth_proximity: {earth_prox}
earth_tension: ~
# X. Narrative Profile
primary_archetype: ~
secondary_archetype: ~
narrative_notable: ~
generation_priority: ~
# XI. Content Notes
stability_index: ~
system_volatility: ~
cultural_corridor: ~
---
# {system_name}
**{astro_id}** | {sid} | {star_type}-type | {sector} {band}
## Supply Dependency
<!-- Required for manufacturing/transit_logistics base and narrative_notable systems -->
## Faction Notes
<!-- Significant local factions not covered by standard columns -->
## Silence Topic
<!-- Required for narrative_notable and Institutional Core / Diplomatic Periphery zones -->
## Narrative Hook
<!-- Required if narrative_notable = true. One sentence. -->
## Calibration Note
<!-- Unusual column combinations requiring justification -->
## Derived Fields
<!-- Auto-generated from star-map.json, do not edit manually -->
- **Compound Zone:** {compound}
- **Settlement Age:** {age_str}
- **Hop Distance from Gateway:** {hop}
- **Adjacent Systems:** {adjacent_str}
"""
def main():
with open(STAR_MAP_PATH, "r") as f:
data = json.load(f)
nodes = data["nodes"]
edges = data["edges"]
# Build adjacency map: system_id -> list of connected system_ids
adj: dict[str, list[str]] = {n["system_id"]: [] for n in nodes}
for a, b in edges:
adj[a].append(b)
adj[b].append(a)
# Sort adjacency lists for deterministic output
for sid in adj:
adj[sid].sort()
created = 0
skipped = 0
for node in sorted(nodes, key=lambda n: n["system_id"]):
sid = node["system_id"]
astro_id = node["astronomical_id"]
dirname = gj_to_dirname(astro_id)
outdir = WIKI_DIR / dirname
outfile = outdir / "index.md"
if outfile.exists():
skipped += 1
continue
outdir.mkdir(parents=True, exist_ok=True)
adjacent = adj.get(sid, [])
content = render_stub(node, adjacent)
outfile.write_text(content)
created += 1
print(f"Wiki stubs generated: {created} created, {skipped} skipped (already exist)")
print(f"Output directory: {WIKI_DIR}")
# Print a few examples
print(f"\nExample directories:")
dirs = sorted(WIKI_DIR.iterdir())[:5]
for d in dirs:
if d.is_dir():
idx = d / "index.md"
size = idx.stat().st_size if idx.exists() else 0
print(f" {d.name}/index.md ({size} bytes)")
if __name__ == "__main__":
main()