| """Sandboxed, data-only skill specs for HuggingWizards. |
| |
| A "skill" is a plain JSON object describing *what* happens and *when* — never |
| executable code. The engine interprets a whitelisted set of triggers/effects, |
| so a skill authored by the Game Master (Nemotron) at runtime can hot-plug onto |
| a player without any `exec`/`eval` or arbitrary code execution. |
| |
| Schema (all fields optional except trigger/effect; everything is clamped): |
| { |
| "name": str, # display name |
| "trigger": every_n_attacks|periodic|on_kill, |
| "n": int, # for every_n_attacks |
| "interval": float, # seconds, for periodic |
| "effect": aoe_damage|projectile_nova|heal|summon_ally|shield, |
| "radius": float, # aoe reach |
| "damage": float, |
| "count": int, # nova projectiles / allies summoned |
| "amount": float, # heal / shield amount |
| "slow": float, # 0..1 slow applied by aoe |
| "color": str, # client tint (#rrggbb) |
| } |
| """ |
| from __future__ import annotations |
|
|
| TRIGGERS = {"every_n_attacks", "periodic", "on_kill"} |
| EFFECTS = {"aoe_damage", "projectile_nova", "heal", "summon_ally", "shield"} |
|
|
| |
| _BOUNDS = { |
| "n": (2, 20), |
| "interval": (1.0, 15.0), |
| "radius": (40.0, 260.0), |
| "damage": (0.0, 120.0), |
| "count": (1, 12), |
| "amount": (0.0, 80.0), |
| "slow": (0.0, 0.8), |
| } |
| _DEFAULTS = { |
| "every_n_attacks": {"n": 5}, |
| "periodic": {"interval": 5.0}, |
| "aoe_damage": {"radius": 120.0, "damage": 35.0, "slow": 0.0}, |
| "projectile_nova": {"count": 8, "damage": 22.0}, |
| "heal": {"amount": 25.0}, |
| "shield": {"amount": 25.0}, |
| "summon_ally": {"count": 1}, |
| } |
| _HEX = set("0123456789abcdefABCDEF") |
| MAX_SKILLS_PER_PLAYER = 6 |
|
|
|
|
| def _num(v, lo, hi, integer=False): |
| try: |
| v = float(v) |
| except Exception: |
| v = lo |
| v = lo if v < lo else hi if v > hi else v |
| return int(round(v)) if integer else round(v, 2) |
|
|
|
|
| def _color(v): |
| if isinstance(v, str) and v.startswith("#") and len(v) == 7 and all(c in _HEX for c in v[1:]): |
| return v |
| return "#b48cff" |
|
|
|
|
| def validate_skill(spec: dict) -> dict | None: |
| """Coerce an arbitrary dict into a safe, bounded skill — or None if unusable.""" |
| if not isinstance(spec, dict): |
| return None |
| trigger = spec.get("trigger") |
| effect = spec.get("effect") |
| if trigger not in TRIGGERS or effect not in EFFECTS: |
| return None |
| out = { |
| "name": str(spec.get("name", "Arcane Skill"))[:32] or "Arcane Skill", |
| "trigger": trigger, |
| "effect": effect, |
| "color": _color(spec.get("color")), |
| } |
| src = {**_DEFAULTS.get(trigger, {}), **_DEFAULTS.get(effect, {}), **spec} |
| if trigger == "every_n_attacks": |
| out["n"] = _num(src.get("n"), *_BOUNDS["n"], integer=True) |
| elif trigger == "periodic": |
| out["interval"] = _num(src.get("interval"), *_BOUNDS["interval"]) |
| if effect == "aoe_damage": |
| out["radius"] = _num(src.get("radius"), *_BOUNDS["radius"]) |
| out["damage"] = _num(src.get("damage"), *_BOUNDS["damage"]) |
| out["slow"] = _num(src.get("slow"), *_BOUNDS["slow"]) |
| elif effect == "projectile_nova": |
| out["count"] = _num(src.get("count"), *_BOUNDS["count"], integer=True) |
| out["damage"] = _num(src.get("damage"), *_BOUNDS["damage"]) |
| elif effect in ("heal", "shield"): |
| out["amount"] = _num(src.get("amount"), *_BOUNDS["amount"]) |
| elif effect == "summon_ally": |
| out["count"] = _num(src.get("count"), *_BOUNDS["count"], integer=True) |
| return out |
|
|
|
|
| |
| CARD_SKILLS = { |
| "aoe": {"name": "Arcane Nova", "trigger": "every_n_attacks", "n": 6, |
| "effect": "aoe_damage", "radius": 130, "damage": 40, "color": "#7ee0ff"}, |
| "summoner": {"name": "Spirit Caller", "trigger": "every_n_attacks", "n": 8, |
| "effect": "summon_ally", "count": 1, "color": "#a6ff8c"}, |
| |
| "war_drums": {"name": "War Drums", "trigger": "periodic", "interval": 7, |
| "effect": "summon_ally", "count": 2, "color": "#ff9d6e"}, |
| "savage_roar": {"name": "Savage Roar", "trigger": "on_kill", |
| "effect": "aoe_damage", "radius": 110, "damage": 30, "color": "#ff6e6e"}, |
| "iron_nova": {"name": "Iron Detonation", "trigger": "every_n_attacks", "n": 5, |
| "effect": "aoe_damage", "radius": 160, "damage": 55, "slow": 0.3, "color": "#c9d4e0"}, |
| "bulwark": {"name": "Bulwark", "trigger": "periodic", "interval": 5, |
| "effect": "heal", "amount": 35, "color": "#9db4c9"}, |
| "impaling_charge": {"name": "Impaling Charge", "trigger": "every_n_attacks", "n": 4, |
| "effect": "projectile_nova", "count": 6, "damage": 38, "color": "#ffd45e"}, |
| "arrow_storm": {"name": "Arrow Storm", "trigger": "periodic", "interval": 4, |
| "effect": "projectile_nova", "count": 12, "damage": 20, "color": "#a6ff8c"}, |
| |
| "holy_judgment": {"name": "Holy Judgment", "trigger": "periodic", "interval": 4, |
| "effect": "aoe_damage", "radius": 180, "damage": 70, "color": "#ffe9a0"}, |
| "glitch_rift": {"name": "Glitch Rift", "trigger": "periodic", "interval": 3.5, |
| "effect": "projectile_nova", "count": 14, "damage": 30, "color": "#7CFC00"}, |
| } |
|
|