258 lines
8.1 KiB
Python
Executable file
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:]))
|