from __future__ import annotations import argparse import json import sys from dataclasses import replace from pathlib import Path import numpy as np ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from life_game.game import GAME_MODES, SANDBOX_MODE, new_game from life_game.tuning import ( ALLOWED_DATA_KEYS, DATA_RANGES, ModeTuning, TUNING_SCHEMA, TuningProfile, apply_tuning_profile, load_tuning_profile, parse_tuning_profile, tuning_profile_to_dict, ) Recipe = str RECIPE_TARGET_SCALES: dict[Recipe, float] = { "easier": 0.85, "harder": 1.15, "shorter": 0.75, "longer": 1.25, } RECIPE_HEALTH_DELTAS: dict[Recipe, int] = { "easier": 1, "harder": -1, "shorter": 0, "longer": 0, } def main() -> None: parser = argparse.ArgumentParser(description="Create deterministic Signal Garden tuning profile mutations.") parser.add_argument("--input", default="", help="Optional base tuning JSON profile.") parser.add_argument("--output", required=True, help="Destination tuning JSON profile.") parser.add_argument("--recipe", choices=sorted(RECIPE_TARGET_SCALES), required=True) parser.add_argument( "--mode", action="append", default=[], help="Playable mode to tune. Repeat for multiple modes. Defaults to every registered arcade mode.", ) parser.add_argument("--size", type=int, default=24, help="Board size used to inspect mode defaults.") args = parser.parse_args() profile = load_tuning_profile(args.input) modes = tuple(args.mode) if args.mode else tuple(mode for mode in GAME_MODES if mode != SANDBOX_MODE) mutated = mutate_profile(profile, args.recipe, modes, max(12, int(args.size))) output = Path(args.output).expanduser() output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(tuning_profile_to_dict(mutated), indent=2, sort_keys=True) + "\n", encoding="utf-8") print(f"Wrote {output}") def mutate_profile(profile: TuningProfile, recipe: Recipe, modes: tuple[str, ...], size: int = 24) -> TuningProfile: if recipe not in RECIPE_TARGET_SCALES: raise ValueError(f"Unknown recipe: {recipe}") next_modes = dict(profile.modes) for index, mode in enumerate(modes): if mode == SANDBOX_MODE or mode not in GAME_MODES: raise ValueError(f"Cannot tune unsupported mode: {mode}") base_game = new_game(size, mode, np.random.default_rng(index + 17)) base_game = apply_tuning_profile(base_game, profile) existing = next_modes.get(mode, ModeTuning()) next_modes[mode] = _mutate_mode(existing, base_game.health, base_game.max_health, dict(base_game.data), recipe) description = profile.description or f"Generated by mutate_tuning.py recipe={recipe}" return TuningProfile(modes=next_modes, description=description) def _mutate_mode(existing: ModeTuning, health: int, max_health: int, data: dict[str, object], recipe: Recipe) -> ModeTuning: health_delta = RECIPE_HEALTH_DELTAS[recipe] target_scale = RECIPE_TARGET_SCALES[recipe] next_max_health = max(1, min(100, int(max_health) + health_delta)) next_health = max(1, min(next_max_health, int(health) + health_delta)) if recipe in {"shorter", "longer"}: next_health = existing.health if existing.health is not None else None next_max_health = existing.max_health if existing.max_health is not None else None next_data = dict(existing.data) for key, value in data.items(): if key not in ALLOWED_DATA_KEYS or isinstance(value, bool) or not isinstance(value, (int, float)): continue low, high = DATA_RANGES[key] scaled = _scale_value(value, target_scale, low, high) next_data[key] = scaled candidate = TuningProfile(modes={"candidate": ModeTuning(health=next_health, max_health=next_max_health, data=next_data)}) parse_tuning_profile(tuning_profile_to_dict(candidate)) return replace(existing, health=next_health, max_health=next_max_health, data=next_data) def _scale_value(value: int | float, scale: float, low: float, high: float) -> int | float: scaled = max(low, min(high, float(value) * scale)) if isinstance(value, int): return int(round(scaled)) return round(scaled, 3) if __name__ == "__main__": main()