Files
root 948d98accb Initial commit: bs-roster-parser v1.0.0
Python library for parsing BattleScribe/NewRecruit roster JSON.
Extracted from Nachmund Tracker's processJSON, ported JS→Python.

- parse_roster(json_string) / parse_roster_file(path) → RosterSummary
- Extracts: unit name, pts, CP, model count, weapon breakdown per model variant
- Handles: variable-model-count units, nested costs, compound upgrades
- Unicode apostrophe-safe unit lookup (find_unit)
- to_dict() for JSON serialization
- 8/8 tests passing on real roster data (27 units, 2815pts)
2026-06-18 03:04:16 +00:00

341 lines
11 KiB
Python

"""
Core roster parser — recursively walks BSApp/NewRecruit roster JSON trees
and extracts flat unit entries with costs, model counts, and weapon breakdowns.
Extracted from the Nachmund Tracker's processJSON function (src/app.js ~lines 648-764),
ported from JavaScript to Python and generalized to be reusable.
"""
import json
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class WeaponProfile:
"""A weapon selection within a unit."""
name: str
@dataclass
class ModelVariant:
"""A distinct model type within a unit (e.g. 'Sergeant' vs 'Battle Sister')."""
name: str
count: int
weapons: list[str] = field(default_factory=list)
@dataclass
class Unit:
"""A single unit entry extracted from a roster."""
name: str
type: str # 'model' or 'unit'
pts: float # points cost (including descendants)
cp: float # crusade points (0 if none)
model_count: int # total models in the unit
breakdown: list[ModelVariant] = field(default_factory=list)
custom_name: Optional[str] = None # custom name if set in BSApp
@dataclass
class RosterSummary:
"""Parsed roster with all units extracted."""
roster_name: str
game_system: str
points_limit: Optional[int]
units: list[Unit]
total_pts: float
total_cp: float
faction: Optional[str] = None # detected from force catalogue name
@property
def unit_count(self) -> int:
return len(self.units)
@property
def total_models(self) -> int:
return sum(u.model_count for u in self.units)
def find_unit(self, name: str) -> Optional[Unit]:
"""Find a unit by name (case-insensitive, unicode-apostrophe-safe). Returns first match."""
# Normalize curly apostrophes to straight ones for matching
name_norm = name.replace("\u2019", "'").replace("\u2018", "'").lower()
for u in self.units:
u_name = u.name.replace("\u2019", "'").replace("\u2018", "'").lower()
if u_name == name_norm:
return u
return None
def to_dict(self) -> dict:
"""Serialize to a plain dict for JSON export."""
return {
"roster_name": self.roster_name,
"game_system": self.game_system,
"points_limit": self.points_limit,
"faction": self.faction,
"total_pts": self.total_pts,
"total_cp": self.total_cp,
"unit_count": self.unit_count,
"total_models": self.total_models,
"units": [
{
"name": u.name,
"type": u.type,
"pts": u.pts,
"cp": u.cp,
"model_count": u.model_count,
"custom_name": u.custom_name,
"breakdown": [
{"name": m.name, "count": m.count, "weapons": m.weapons}
for m in u.breakdown
],
}
for u in self.units
],
}
# ── Cost extraction helpers ──
def _extract_cost(node: dict, exact_matches: list[str], prop_names: list[str]) -> float:
"""Extract a cost value from a node, checking costs[] array first, then direct properties."""
val = 0.0
costs = node.get("costs")
if isinstance(costs, list):
for c in costs:
cname = (c.get("name") or "").lower().strip()
if cname in exact_matches:
val += float(c.get("value", 0))
break
if val == 0:
for prop in prop_names:
if prop in node and node[prop] is not None:
val += float(node[prop])
break
return val
def _sum_descendant_pts(obj) -> float:
"""Recursively sum pts from all descendant selections.
Handles variable-model-count units where each child model carries its own pts."""
if isinstance(obj, list):
return sum(_sum_descendant_pts(item) for item in obj)
if not isinstance(obj, dict) or obj is None:
return 0.0
pts = 0.0
costs = obj.get("costs")
if isinstance(costs, list):
for c in costs:
cname = (c.get("name") or "").lower().strip()
if cname in ("pts", "points"):
pts += float(c.get("value", 0))
break
selections = obj.get("selections")
if isinstance(selections, list):
pts += _sum_descendant_pts(selections)
return pts
def _sum_descendant_models(obj) -> int:
"""Recursively count all model nodes in a subtree (summing their number fields)."""
if isinstance(obj, list):
return sum(_sum_descendant_models(item) for item in obj)
if not isinstance(obj, dict) or obj is None:
return 0
count = 0
if obj.get("type") == "model":
count += int(obj.get("number", 1))
selections = obj.get("selections")
if isinstance(selections, list):
count += _sum_descendant_models(selections)
return count
# ── Weapon detection ──
def _get_weapon_names(selections) -> list[str]:
"""Extract weapon names from a selections list, recursing into compound upgrades."""
weapons = []
if not isinstance(selections, list):
return weapons
for sel in selections:
if not isinstance(sel, dict):
continue
profiles = sel.get("profiles")
if isinstance(profiles, list):
for p in profiles:
ptype = (p.get("typeName") or "").lower()
if "weapon" in ptype and sel.get("name") and sel["name"] not in weapons:
weapons.append(sel["name"])
break
# Recurse into compound upgrades (e.g. "2 Plasma Cannons")
sub_weapons = _get_weapon_names(sel.get("selections"))
for w in sub_weapons:
if w not in weapons:
weapons.append(w)
return weapons
# ── Model breakdown builder ──
def _build_model_breakdown(unit_obj: dict) -> list[ModelVariant]:
"""Build a list of {name, count, weapons} for every unique model variant in a unit."""
model_map: dict[str, dict] = {}
def collect_models(obj):
if isinstance(obj, list):
for item in obj:
collect_models(item)
return
if not isinstance(obj, dict) or obj is None:
return
if obj.get("type") == "model":
name = obj.get("name") or "Unknown"
n = int(obj.get("number", 1))
if name not in model_map:
model_map[name] = {"count": 0, "weapons": []}
model_map[name]["count"] += n
weapons = _get_weapon_names(obj.get("selections"))
for w in weapons:
if w not in model_map[name]["weapons"]:
model_map[name]["weapons"].append(w)
return
selections = obj.get("selections")
if isinstance(selections, list):
for s in selections:
collect_models(s)
selections = unit_obj.get("selections")
if isinstance(selections, list):
for s in selections:
collect_models(s)
return [
ModelVariant(name=name, count=d["count"], weapons=d["weapons"])
for name, d in model_map.items()
]
# ── Main tree walker ──
def _search_tree(obj, entries: list[Unit]):
"""Recursively walk the roster tree, extracting unit/model entries."""
if isinstance(obj, list):
for item in obj:
_search_tree(item, entries)
return
if not isinstance(obj, dict) or obj is None:
return
# Look for unit/model entries that come directly from a catalogue entry
if obj.get("from") == "entry" and obj.get("type") in ("model", "unit"):
display_name = (
obj.get("customName")
or obj.get("custom_name")
or obj.get("name")
or "Unnamed Entry"
)
count = int(obj.get("number", 1)) if obj.get("number") else 1
pts = _extract_cost(obj, ["pts", "points"], ["pts", "points"])
# Sum pts from child selections (handles variable-model-count units
# and enhancement pts costs added to units with a base cost)
selections = obj.get("selections")
if isinstance(selections, list):
pts += _sum_descendant_pts(selections)
cp = _extract_cost(obj, ["cp", "crusade points"], ["crusadePoints", "cp"])
# Count models
model_count = 0
if obj.get("type") == "unit" and isinstance(selections, list):
model_count = _sum_descendant_models(selections)
elif obj.get("type") == "model":
model_count = int(obj.get("number", 1))
breakdown = _build_model_breakdown(
obj if obj.get("type") == "unit" else {"selections": [obj]}
)
for _ in range(count):
entries.append(Unit(
name=display_name,
type=obj["type"],
pts=pts,
cp=cp,
model_count=model_count,
breakdown=breakdown,
custom_name=obj.get("customName") or obj.get("custom_name"),
))
return
# Recurse into all values
for v in obj.values():
if isinstance(v, (dict, list)):
_search_tree(v, entries)
# ── Public API ──
def parse_roster(json_str: str) -> RosterSummary:
"""Parse a BattleScribe/NewRecruit roster JSON string into a RosterSummary.
Args:
json_str: Raw JSON string from BSApp/NewRecruit roster export.
Returns:
RosterSummary with all units, costs, and model counts extracted.
Raises:
json.JSONDecodeError: If the input is not valid JSON.
KeyError: If the roster structure is missing required fields.
"""
data = json.loads(json_str)
roster = data.get("roster", data)
roster_name = roster.get("name", "Unknown Roster")
game_system = roster.get("gameSystemName", "Unknown")
# Extract points limit
points_limit = None
cost_limits = roster.get("costLimits", [])
for cl in cost_limits:
if (cl.get("name") or "").lower() == "pts":
points_limit = int(cl.get("value", 0))
break
# Detect faction from first force's catalogue
faction = None
forces = roster.get("forces", [])
if forces:
faction = forces[0].get("name") or forces[0].get("catalogueName")
# Walk the tree
entries: list[Unit] = []
_search_tree(roster, entries)
total_pts = sum(u.pts for u in entries)
total_cp = sum(u.cp for u in entries)
return RosterSummary(
roster_name=roster_name,
game_system=game_system,
points_limit=points_limit,
units=entries,
total_pts=total_pts,
total_cp=total_cp,
faction=faction,
)
def parse_roster_file(path: str) -> RosterSummary:
"""Parse a roster JSON file from disk.
Args:
path: Path to the roster JSON file.
Returns:
RosterSummary with all units extracted.
"""
with open(path, "r", encoding="utf-8") as f:
return parse_roster(f.read())