magicciv/tools/fauna-derive-stats.py
Natalie 3c76b6b47b fix(@projects/@magic-civilization): 🐛 update fauna species data files
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-04 07:12:33 -04:00

258 lines
8.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""Derive fauna combat stats from traits per FAUNA_COMBAT_STATS.md.
Walks `public/resources/ecology/fauna/species/*.json`. For every species that
carries the `size_*`, `diet_*`, and `locomotion_*` traits (and lacks a
hand-authored `combat_profile`), recomputes the flat combat block:
hp, attack, defense, armor_type, attack_type, movement, _derived_stats
…and writes it back into the JSON file (preserving 2-space indent + trailing
newline). Runs are idempotent: a second run produces no diff.
Skip rules:
- Species with `combat_profile` (apex / boss schema, hand-tuned per p1-58).
- Species missing any of size_*, diet_*, locomotion_* traits.
Formulas mirror `src/simulator/crates/mc-ecology/src/combat.rs`. The Rust crate
is the runtime source of truth; this tool keeps the JSON canonical store
(`public/resources/ecology/fauna/species/*.json`) consistent with it.
Usage:
python3 tools/fauna-derive-stats.py # write derived fields
python3 tools/fauna-derive-stats.py --check # exit 1 if any drift
The --check mode is the pre-commit gate (see `tools/fauna-derive-check.py`).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parent.parent
SPECIES_DIR = REPO_ROOT / "public" / "resources" / "ecology" / "fauna" / "species"
# ── Trait tables (mirror mc-ecology/src/combat.rs lines 159-340) ─────────
SIZE_HP_BASE = {
"tiny": 5,
"small": 12,
"medium": 25,
"large": 40,
"huge": 60,
}
SIZE_ATK_BASE = {
"tiny": 3,
"small": 6,
"medium": 10,
"large": 16,
"huge": 22,
}
SIZE_DEF_BASE = {
"tiny": 2,
"small": 4,
"medium": 8,
"large": 14,
"huge": 20,
}
SIZE_ARMOR_TYPE = {
"tiny": "unarmored",
"small": "unarmored",
"medium": "light",
"large": "medium",
"huge": "heavy",
}
DIET_ATK_MULT = {
"herbivore": 0.5,
"omnivore": 0.8,
"carnivore": 1.2,
"detritivore": 0.3,
"scavenger": 0.4,
"producer": 0.0,
"filter_feeder": 0.0,
"parasite": 0.0,
}
LOCOMOTION_MOVEMENT = {
"sessile": 0,
"walking": 2,
"climbing": 2,
"flying": 4,
"burrowing": 1,
"swimming": 3,
"slithering": 2,
"drifting": 1,
"crawling": 1,
"running": 3,
}
# Tier modifiers per FAUNA_COMBAT_STATS.md.
HP_TIER_FACTOR = 0.10 # +10% per tier above 1
ATK_TIER_FACTOR = 0.08 # +8% per tier above 1
DEF_TIER_FACTOR = 0.10 # +10% per tier above 1
# Fields the tool writes. Hand-edits to these are rejected by the gate.
DERIVED_FIELDS = ("hp", "attack", "defense", "movement", "armor_type", "attack_type")
MARKER_FIELD = "_derived_stats"
def extract_trait(traits: list[str], prefix: str) -> str | None:
"""Return the suffix of the first trait matching `prefix_*`, or None."""
pre = f"{prefix}_"
for t in traits:
if t.startswith(pre):
return t[len(pre):]
return None
def derive_attack_type(diet: str, size: str, social: str | None, locomotion: str) -> str:
"""Mirror combat.rs::attack_type."""
if social == "swarm":
return "pierce"
if locomotion == "burrowing":
return "pierce"
if diet == "carnivore":
return "pierce" if size in ("tiny", "small", "medium") else "blade"
if diet == "herbivore":
return "trample" if size in ("large", "huge") else "none"
if diet == "omnivore":
return "crush"
return "none"
def round_stat(x: float) -> int:
"""Round-half-away-from-zero to int; combat stats are integers in JSON."""
return int(x + 0.5) if x >= 0 else -int(-x + 0.5)
def compute_derived(traits: list[str], tier: int) -> dict[str, Any] | None:
"""Compute the derived combat block, or None if traits are insufficient."""
size = extract_trait(traits, "size")
diet = extract_trait(traits, "diet")
locomotion = extract_trait(traits, "locomotion")
social = extract_trait(traits, "social")
if size not in SIZE_HP_BASE:
return None
if diet not in DIET_ATK_MULT:
return None
if locomotion not in LOCOMOTION_MOVEMENT:
return None
tier = max(1, int(tier))
tier_step = tier - 1
hp = SIZE_HP_BASE[size] * (1.0 + HP_TIER_FACTOR * tier_step)
atk = SIZE_ATK_BASE[size] * DIET_ATK_MULT[diet] * (1.0 + ATK_TIER_FACTOR * tier_step)
dfn = SIZE_DEF_BASE[size] * (1.0 + DEF_TIER_FACTOR * tier_step)
movement = LOCOMOTION_MOVEMENT[locomotion]
attack_type = derive_attack_type(diet, size, social, locomotion)
armor_type = SIZE_ARMOR_TYPE[size]
return {
"hp": round_stat(hp),
"attack": round_stat(atk),
"defense": round_stat(dfn),
"armor_type": armor_type,
"attack_type": attack_type,
"movement": movement,
MARKER_FIELD: True,
}
def apply_derived(species: dict[str, Any], derived: dict[str, Any]) -> dict[str, Any]:
"""Return a new species dict with derived fields overwritten."""
out = dict(species)
# Strip prior derived fields so insertion order is deterministic.
for f in DERIVED_FIELDS:
out.pop(f, None)
out.pop(MARKER_FIELD, None)
# Insert derived block as a contiguous group at the end.
out["hp"] = derived["hp"]
out["attack"] = derived["attack"]
out["defense"] = derived["defense"]
out["armor_type"] = derived["armor_type"]
out["attack_type"] = derived["attack_type"]
out["movement"] = derived["movement"]
out[MARKER_FIELD] = True
return out
def serialize(obj: dict[str, Any]) -> str:
"""Serialize with 2-space indent and trailing newline (matches existing files)."""
return json.dumps(obj, indent=2, ensure_ascii=False) + "\n"
def process(check_only: bool) -> int:
if not SPECIES_DIR.is_dir():
print(f"ERROR: species dir not found: {SPECIES_DIR}", file=sys.stderr)
return 2
files = sorted(SPECIES_DIR.glob("*.json"))
skipped_apex: list[str] = []
skipped_no_traits: list[str] = []
derived_count = 0
drift: list[str] = []
for path in files:
with path.open("r", encoding="utf-8") as f:
original_text = f.read()
species = json.loads(original_text)
# Apex / boss schema: hand-tuned, never derive.
if "combat_profile" in species:
skipped_apex.append(path.name)
continue
traits = species.get("traits", [])
tier = species.get("ecology_tier", 1)
derived = compute_derived(traits, tier)
if derived is None:
skipped_no_traits.append(path.name)
continue
new_species = apply_derived(species, derived)
new_text = serialize(new_species)
derived_count += 1
if new_text != original_text:
if check_only:
drift.append(path.name)
else:
with path.open("w", encoding="utf-8") as f:
f.write(new_text)
print(f"fauna-derive-stats: derived={derived_count} "
f"apex_skipped={len(skipped_apex)} no_traits_skipped={len(skipped_no_traits)} "
f"total={len(files)}")
if skipped_apex:
print(f" apex (combat_profile): {', '.join(skipped_apex)}")
if skipped_no_traits:
print(f" no traits: {', '.join(skipped_no_traits[:5])}"
+ (f" (+{len(skipped_no_traits)-5} more)" if len(skipped_no_traits) > 5 else ""))
if check_only and drift:
print(f"\nFAIL: {len(drift)} species file(s) have stale derived stats:",
file=sys.stderr)
for name in drift[:20]:
print(f" - {name}", file=sys.stderr)
if len(drift) > 20:
print(f" … (+{len(drift)-20} more)", file=sys.stderr)
print("\nFix-up: python3 tools/fauna-derive-stats.py", file=sys.stderr)
return 1
return 0
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--check", action="store_true",
help="Diff-only: exit 1 if any derived block is stale.")
args = parser.parse_args(argv)
return process(check_only=args.check)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))