nation_optimizer / core /config.py
thewildofficial
feat(core): implement simulation primitives
f2aa0c0
Raw
History Blame Contribute Delete
5.99 kB
"""
config.py β€” All game constants and tunable parameters.
Single source of truth. Reads sector definitions from sectors.json.
Frozen dataclass: safe to share across modules, cannot be mutated.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import ClassVar
INITIAL_TREASURY = 1000
BASELINE_TAX = 100
INITIAL_POPULATION = 1_000_000
INITIAL_PRODUCTIVITY = 1.0
MIN_PRODUCTIVITY = 0.5
MAX_PRODUCTIVITY = 2.0
MAX_ROUNDS = 50
CRITICAL_RATIO = 0.4
SURPLUS_RATIO = 1.5
WASTAGE_RATIO = 2.5
RF_MAX = 1.8
PRODUCTIVITY_STEP = 0.05
BASE_BIRTH_RATE = 0.005
BASE_DEATH_RATE = 0.002
CRISIS_DEATH_PENALTY = 0.01
@dataclass(frozen=True)
class GameConfig:
"""Immutable configuration for a NationGame episode."""
# ── Population ──────────────────────────────────────────────
POP_0: int = INITIAL_POPULATION
# ── Treasury ────────────────────────────────────────────────
INITIAL_TREASURY: float = float(INITIAL_TREASURY)
BASELINE_TAX: float = float(BASELINE_TAX)
# ── Productivity ────────────────────────────────────────────
INITIAL_PRODUCTIVITY: float = INITIAL_PRODUCTIVITY
PRODUCTIVITY_MIN: float = MIN_PRODUCTIVITY
PRODUCTIVITY_MAX: float = MAX_PRODUCTIVITY
PRODUCTIVITY_STEP: float = PRODUCTIVITY_STEP
# ── Revenue-curve ratios (relative to Demand) ───────────────
CRITICAL_RATIO: float = CRITICAL_RATIO
SURPLUS_RATIO: float = SURPLUS_RATIO
WASTAGE_RATIO: float = WASTAGE_RATIO
RF_MAX: float = RF_MAX # peak revenue factor at surplus
# ── Population dynamics ─────────────────────────────────────
BIRTH_RATE_BASE: float = BASE_BIRTH_RATE
DEATH_RATE_BASE: float = BASE_DEATH_RATE
CRISIS_DEATH_PENALTY: float = CRISIS_DEATH_PENALTY
# ── Episode limits ──────────────────────────────────────────
MAX_ROUNDS: int = MAX_ROUNDS
SHUTDOWN_THRESHOLD: int = 2 # consecutive zero-alloc rounds
# ── Reward components ───────────────────────────────────────
PRODUCTIVITY_BONUS_SCALE: float = 50.0
SURVIVAL_BONUS_PER_ROUND: float = 10.0
OVER_ALLOC_PENALTY: float = -5.0
UNDER_ALLOC_PENALTY: float = -10.0
CRITICAL_PENALTY: float = -1000.0
BANKRUPTCY_PENALTY: float = -1000.0
# ── Prosperity threshold (early-success termination) ────────
PROSPERITY_THRESHOLD: float | None = None # None = disabled
PROSPERITY_STREAK: int = 5 # consecutive rounds above threshold
# ── Sector data (populated from JSON) ───────────────────────
SECTOR_BASELINES: dict[str, int] = field(default_factory=dict)
SECTOR_ORDER: tuple[str, ...] = ()
SECTOR_META: dict[str, dict] = field(default_factory=dict)
# ── Class-level path to default JSON ────────────────────────
_DEFAULT_JSON: ClassVar[Path] = Path(__file__).parent / "sectors.json"
# ── Factory ─────────────────────────────────────────────────
@classmethod
def from_json(cls, path: str | Path | None = None, **overrides) -> "GameConfig":
"""
Build a GameConfig from sectors.json.
Any keyword argument overrides the corresponding field.
"""
path = Path(path) if path else cls._DEFAULT_JSON
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
sectors_raw: dict = data.get("sectors", {})
baselines = {name: info["baseline"] for name, info in sectors_raw.items()}
order = tuple(sectors_raw.keys())
meta = {
name: {
"full_name": info.get("full_name", name),
"description": info.get("description", ""),
}
for name, info in sectors_raw.items()
}
# Merge JSON-level scalars with dataclass defaults
json_fields = {
"POP_0": data.get("initial_population"),
"INITIAL_TREASURY": data.get("initial_treasury"),
"BASELINE_TAX": data.get("baseline_tax"),
"INITIAL_PRODUCTIVITY": data.get("initial_productivity"),
"PRODUCTIVITY_MIN": (data.get("productivity_bounds") or [None])[0],
"PRODUCTIVITY_MAX": (data.get("productivity_bounds") or [None, None])[1],
"PRODUCTIVITY_STEP": data.get("productivity_step"),
"MAX_ROUNDS": data.get("max_rounds"),
"CRITICAL_RATIO": data.get("critical_ratio"),
"SURPLUS_RATIO": data.get("surplus_ratio"),
"WASTAGE_RATIO": data.get("wastage_ratio"),
"RF_MAX": data.get("rf_max"),
"BIRTH_RATE_BASE": data.get("birth_rate_base"),
"DEATH_RATE_BASE": data.get("death_rate_base"),
"CRISIS_DEATH_PENALTY": data.get("crisis_death_penalty"),
}
# Drop None values so dataclass defaults apply
json_fields = {k: v for k, v in json_fields.items() if v is not None}
return cls(
**json_fields,
SECTOR_BASELINES=baselines,
SECTOR_ORDER=order,
SECTOR_META=meta,
**overrides,
)
# ── Module-level convenience instance ──────────────────────────
DEFAULT_CONFIG = GameConfig.from_json()