| from __future__ import annotations |
|
|
| import json |
| import os |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any, Literal, cast |
|
|
| TerrainKind = Literal["plain_green"] |
| ConnectorKind = Literal["deterministic", "openai_compatible"] |
| OverseerMode = Literal["off", "advisor", "autopilot"] |
| FORCE_DETERMINISTIC_ENV = "WORLD_SIMULATOR_FORCE_DETERMINISTIC" |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class WorldConfig: |
| width: int |
| depth: int |
| terrain: TerrainKind |
| seed: int |
| survival: bool = False |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class NpcConfig: |
| count: int |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class SimulationConfig: |
| tick_ms: int |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ServerConfig: |
| host: str |
| port: int |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class ConnectorConfig: |
| type: ConnectorKind |
| base_url: str | None = None |
| api_url: str | None = None |
| model: str | None = None |
| api_key_env: str | None = None |
| timeout_seconds: int = 60 |
| max_tokens: int = 1400 |
| temperature: float = 0.6 |
| top_p: float = 0.95 |
| tool_choice: str | dict[str, Any] | None = None |
| max_parallel_npc_calls: int = 5 |
| fallback_to_deterministic: bool = True |
| extra_body: dict[str, Any] | None = None |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class OverseerConfig: |
| mode: OverseerMode = "off" |
| cycle_ticks: int = 8 |
| max_directives: int = 3 |
| connector: ConnectorConfig = field( |
| default_factory=lambda: ConnectorConfig(type="deterministic") |
| ) |
|
|
|
|
| @dataclass(frozen=True, slots=True) |
| class GameConfig: |
| world: WorldConfig |
| npcs: NpcConfig |
| simulation: SimulationConfig |
| server: ServerConfig |
| connector: ConnectorConfig = field( |
| default_factory=lambda: ConnectorConfig(type="deterministic") |
| ) |
| god_console: ConnectorConfig | None = None |
| overseer: OverseerConfig = field(default_factory=OverseerConfig) |
| secondary_connectors: dict[str, ConnectorConfig] = field(default_factory=dict) |
|
|
|
|
| def load_game_config(path: Path) -> GameConfig: |
| _load_dotenv_for_config(path) |
| with path.open("r", encoding="utf-8") as config_file: |
| raw = json.load(config_file) |
| return parse_game_config(raw) |
|
|
|
|
| def apply_runtime_env_overrides(config: GameConfig) -> GameConfig: |
| if not _truthy_env(os.getenv(FORCE_DETERMINISTIC_ENV)): |
| return config |
|
|
| deterministic_connector = ConnectorConfig(type="deterministic") |
| return GameConfig( |
| world=config.world, |
| npcs=config.npcs, |
| simulation=config.simulation, |
| server=config.server, |
| connector=deterministic_connector, |
| god_console=None, |
| overseer=OverseerConfig( |
| mode=config.overseer.mode, |
| cycle_ticks=config.overseer.cycle_ticks, |
| max_directives=config.overseer.max_directives, |
| connector=deterministic_connector, |
| ), |
| ) |
|
|
|
|
| def parse_game_config(raw: dict[str, Any]) -> GameConfig: |
| world = _required_mapping(raw, "world") |
| npcs = _required_mapping(raw, "npcs") |
| simulation = _required_mapping(raw, "simulation") |
| server = _required_mapping(raw, "server") |
| connector = _optional_mapping(raw, "connector") or {"type": "deterministic"} |
| god_console = _optional_mapping(raw, "god_console") |
| overseer = _optional_mapping(raw, "overseer") |
|
|
| terrain_raw = _required_str(world, "terrain") |
| if terrain_raw != "plain_green": |
| raise ValueError(f"Unsupported terrain kind: {terrain_raw}") |
| terrain: TerrainKind = "plain_green" |
|
|
| secondary_raw = raw.get("secondary_connectors", {}) |
| if not isinstance(secondary_raw, dict): |
| raise ValueError("'secondary_connectors' must be an object.") |
| secondary_connectors = { |
| cid: _parse_connector_config(cfg) |
| for cid, cfg in secondary_raw.items() |
| if isinstance(cfg, dict) |
| } |
|
|
| return GameConfig( |
| world=WorldConfig( |
| width=_required_positive_int(world, "width"), |
| depth=_required_positive_int(world, "depth"), |
| terrain=terrain, |
| seed=_required_int(world, "seed"), |
| survival=_optional_bool(world, "survival", default=False), |
| ), |
| npcs=NpcConfig(count=_required_non_negative_int(npcs, "count")), |
| simulation=SimulationConfig(tick_ms=_required_positive_int(simulation, "tick_ms")), |
| server=ServerConfig( |
| host=_required_str(server, "host"), |
| port=_required_positive_int(server, "port"), |
| ), |
| connector=_parse_connector_config(connector), |
| god_console=_parse_connector_config(god_console) if god_console else None, |
| overseer=_parse_overseer_config(overseer), |
| secondary_connectors=secondary_connectors, |
| ) |
|
|
|
|
| def _parse_overseer_config(raw: dict[str, Any] | None) -> OverseerConfig: |
| if raw is None: |
| return OverseerConfig() |
| mode_raw = _optional_str(raw, "mode") or "off" |
| if mode_raw not in ("off", "advisor", "autopilot"): |
| raise ValueError(f"Unsupported overseer mode: {mode_raw}") |
| connector_raw = _optional_mapping(raw, "connector") |
| if connector_raw is None: |
| connector_keys = { |
| "type", |
| "base_url", |
| "api_url", |
| "model", |
| "api_key_env", |
| "timeout_seconds", |
| "max_tokens", |
| "temperature", |
| "top_p", |
| "tool_choice", |
| "max_parallel_npc_calls", |
| "fallback_to_deterministic", |
| "extra_body", |
| "base_url_env", |
| "api_url_env", |
| "model_env", |
| } |
| connector_raw = { |
| key: value |
| for key, value in raw.items() |
| if key in connector_keys |
| } or {"type": "deterministic"} |
| return OverseerConfig( |
| mode=cast(OverseerMode, mode_raw), |
| cycle_ticks=_optional_positive_int(raw, "cycle_ticks") or 8, |
| max_directives=_optional_positive_int(raw, "max_directives") or 3, |
| connector=_parse_connector_config(connector_raw), |
| ) |
|
|
|
|
| def _parse_connector_config(raw: dict[str, Any]) -> ConnectorConfig: |
| connector_type_raw = _optional_str(raw, "type") or "deterministic" |
| if connector_type_raw not in ("deterministic", "openai_compatible"): |
| raise ValueError(f"Unsupported connector type: {connector_type_raw}") |
|
|
| connector_type = cast(ConnectorKind, connector_type_raw) |
|
|
| base_url = _optional_str(raw, "base_url") |
| api_url = _optional_str(raw, "api_url") |
| model = _optional_str(raw, "model") |
| base_url_env = _optional_str(raw, "base_url_env") |
| api_url_env = _optional_str(raw, "api_url_env") |
| model_env = _optional_str(raw, "model_env") |
|
|
| env_base_url = os.getenv(base_url_env) if base_url_env else None |
| env_api_url = os.getenv(api_url_env) if api_url_env else None |
|
|
| if env_base_url: |
| base_url = env_base_url |
| elif env_api_url: |
| api_url = env_api_url |
| base_url = None |
| if model_env: |
| model = os.getenv(model_env) or model |
|
|
| temperature = _optional_non_negative_float(raw, "temperature") |
| top_p = _optional_probability(raw, "top_p") |
|
|
| return ConnectorConfig( |
| type=connector_type, |
| base_url=base_url, |
| api_url=api_url, |
| model=model, |
| api_key_env=_optional_str(raw, "api_key_env"), |
| timeout_seconds=_optional_positive_int(raw, "timeout_seconds") or 60, |
| max_tokens=_optional_positive_int(raw, "max_tokens") or 1400, |
| temperature=0.6 if temperature is None else temperature, |
| top_p=0.95 if top_p is None else top_p, |
| tool_choice=_optional_tool_choice(raw, "tool_choice"), |
| max_parallel_npc_calls=_optional_positive_int(raw, "max_parallel_npc_calls") or 5, |
| fallback_to_deterministic=_optional_bool(raw, "fallback_to_deterministic", default=True), |
| extra_body=_optional_mapping(raw, "extra_body"), |
| ) |
|
|
|
|
| def _required_mapping(raw: dict[str, Any], key: str) -> dict[str, Any]: |
| value = raw.get(key) |
| if not isinstance(value, dict): |
| raise ValueError(f"Expected '{key}' to be an object.") |
| return value |
|
|
|
|
| def _optional_mapping(raw: dict[str, Any], key: str) -> dict[str, Any] | None: |
| value = raw.get(key) |
| if value is None: |
| return None |
| if not isinstance(value, dict): |
| raise ValueError(f"Expected '{key}' to be an object.") |
| return value |
|
|
|
|
| def _required_int(raw: dict[str, Any], key: str) -> int: |
| value = raw.get(key) |
| if not isinstance(value, int) or isinstance(value, bool): |
| raise ValueError(f"Expected '{key}' to be an integer.") |
| return value |
|
|
|
|
| def _required_positive_int(raw: dict[str, Any], key: str) -> int: |
| value = _required_int(raw, key) |
| if value <= 0: |
| raise ValueError(f"Expected '{key}' to be positive.") |
| return value |
|
|
|
|
| def _required_non_negative_int(raw: dict[str, Any], key: str) -> int: |
| value = _required_int(raw, key) |
| if value < 0: |
| raise ValueError(f"Expected '{key}' to be non-negative.") |
| return value |
|
|
|
|
| def _optional_positive_int(raw: dict[str, Any], key: str) -> int | None: |
| value = raw.get(key) |
| if value is None: |
| return None |
| if not isinstance(value, int) or isinstance(value, bool): |
| raise ValueError(f"Expected '{key}' to be an integer.") |
| if value <= 0: |
| raise ValueError(f"Expected '{key}' to be positive.") |
| return value |
|
|
|
|
| def _optional_non_negative_float(raw: dict[str, Any], key: str) -> float | None: |
| value = raw.get(key) |
| if value is None: |
| return None |
| if not isinstance(value, int | float) or isinstance(value, bool): |
| raise ValueError(f"Expected '{key}' to be a number.") |
| if value < 0: |
| raise ValueError(f"Expected '{key}' to be non-negative.") |
| return float(value) |
|
|
|
|
| def _optional_probability(raw: dict[str, Any], key: str) -> float | None: |
| value = raw.get(key) |
| if value is None: |
| return None |
| if not isinstance(value, int | float) or isinstance(value, bool): |
| raise ValueError(f"Expected '{key}' to be a number.") |
| if value <= 0 or value > 1: |
| raise ValueError(f"Expected '{key}' to be between 0 and 1.") |
| return float(value) |
|
|
|
|
| def _required_str(raw: dict[str, Any], key: str) -> str: |
| value = raw.get(key) |
| if not isinstance(value, str) or not value: |
| raise ValueError(f"Expected '{key}' to be a non-empty string.") |
| return value |
|
|
|
|
| def _optional_str(raw: dict[str, Any], key: str) -> str | None: |
| value = raw.get(key) |
| if value is None: |
| return None |
| if not isinstance(value, str) or not value: |
| raise ValueError(f"Expected '{key}' to be a non-empty string.") |
| return value |
|
|
|
|
| def _optional_bool(raw: dict[str, Any], key: str, *, default: bool) -> bool: |
| value = raw.get(key) |
| if value is None: |
| return default |
| if not isinstance(value, bool): |
| raise ValueError(f"Expected '{key}' to be a boolean.") |
| return value |
|
|
|
|
| def _optional_tool_choice(raw: dict[str, Any], key: str) -> str | dict[str, Any] | None: |
| value = raw.get(key) |
| if value is None: |
| return None |
| if isinstance(value, str): |
| if not value: |
| raise ValueError(f"Expected '{key}' to be a non-empty string or object.") |
| return value |
| if isinstance(value, dict): |
| return value |
| raise ValueError(f"Expected '{key}' to be a string or object.") |
|
|
|
|
| def _load_dotenv_for_config(config_path: Path) -> None: |
| candidates = [ |
| Path.cwd() / ".env", |
| config_path.resolve().parent / ".env", |
| config_path.resolve().parent.parent / ".env", |
| ] |
| seen: set[Path] = set() |
| for candidate in candidates: |
| if candidate in seen or not candidate.is_file(): |
| continue |
| seen.add(candidate) |
| _load_dotenv(candidate) |
|
|
|
|
| def _load_dotenv(path: Path) -> None: |
| with path.open("r", encoding="utf-8") as dotenv_file: |
| for raw_line in dotenv_file: |
| line = raw_line.strip() |
| if not line or line.startswith("#"): |
| continue |
| if line.startswith("export "): |
| line = line.removeprefix("export ").strip() |
| key, separator, value = line.partition("=") |
| if not separator: |
| continue |
| key = key.strip() |
| if not key: |
| continue |
| os.environ.setdefault(key, _dotenv_value(value)) |
|
|
|
|
| def _dotenv_value(raw: str) -> str: |
| value = raw.strip() |
| if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): |
| return value[1:-1] |
| return value |
|
|
|
|
| def _truthy_env(value: str | None) -> bool: |
| return value is not None and value.strip().lower() in ("1", "true", "yes", "on") |
|
|