#!/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:]))