From 6a0496afa7c55f7048dbe24a65246667a64261ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:50:35 +0000 Subject: [PATCH 1/3] Initial plan From d6cddf20efee30e21a2765b27a2576b5b73fc10c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 18:59:20 +0000 Subject: [PATCH 2/3] Mark Task 8.4.1 (Content Pipeline Tooling & CI) as completed in tracker Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- .pm/tracker.md | 81 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/.pm/tracker.md b/.pm/tracker.md index da2b680d..073cf44c 100644 --- a/.pm/tracker.md +++ b/.pm/tracker.md @@ -1,11 +1,17 @@ # Project Task Tracker -**Last Updated:** 2025-12-02T06:23:51Z +**Last Updated:** 2025-12-02T18:55:00Z ## Status Summary **Recent Progress (since last update):** +- 🎉 **Task 8.4.1 (Content Pipeline Tooling & CI) COMPLETED** - GitHub Issue [#23](https://github.com/TheWizardsCode/GEngine/issues/23) + - Content build script (`scripts/build_content.py`) validates worlds, configs, and sweeps + - CI workflow (`.github/workflows/content-validation.yml`) runs on content file changes + - Designer workflow documented in `docs/gengine/content_designer_workflow.md` + - 17 tests covering all validation paths, all passing + - Clear error messages with entity reference validation - 🎉 **Task 10.1.2 (Strengthen AgentSystem Tests) COMPLETED** - Refactored `AgentSystem` to extract scoring logic for testability. - Added unit tests verifying trait influence (empathy, cunning, resolve) on decision scoring. @@ -92,9 +98,9 @@ **Current Priorities:** -1. 🚀 **Phase 8 Deployment** - Core complete (8.1.1, 8.2.1, 8.3.1), need CI automation (8.3.2) and content pipeline (8.4.1) +1. 🚀 **Phase 8 Deployment** - Core complete (8.1.1, 8.2.1, 8.3.1, 8.4.1), need CI automation (8.3.2) to finish 2. 🤖 **Phase 9 AI Testing** - Observer (9.1.1) and action layer (9.2.1) complete, LLM-enhanced (9.3.1) ready to start -3. 🔧 **CI/CD Gap** - No automated workflows exist; high risk of regressions +3. 🔧 **CI/CD Gap** - K8s validation workflow (8.3.2) still needed for deployment protection **Recommended Next 3 Parallel Tasks:** @@ -105,37 +111,30 @@ - Impact: Protects all environments from manifest errors - Estimated time: 1-2 days -2. **8.3.3 - K8s Resource Tuning** (Priority: MEDIUM, Effort: Low) - - Why: Complete 8.3.1 resource sizing acceptance criteria - - Owner needed: DevOps/SRE-focused agent - - Parallelizable: Configuration work, independent of code - - Impact: Prevents resource exhaustion in production - - Estimated time: 4-6 hours - - Prerequisites: Smoke test data from 8.3.1 - -3. **9.3.1 - LLM-Enhanced AI Decisions** (Priority: MEDIUM, Effort: High) +2. **9.3.1 - LLM-Enhanced AI Decisions** (Priority: MEDIUM, Effort: High) - Why: Builds on completed AI foundation (9.1.1, 9.2.1) - Owner needed: AI/ML-focused agent with LLM experience - Parallelizable: AI/ML work, independent of infrastructure - Impact: Enables advanced AI testing capabilities - Estimated time: 3-5 days -**Alternative (if no AI/ML owner available):** -- **8.4.1 - Content Pipeline Tooling** instead of 9.3.1 - - Priority: MEDIUM, Effort: Medium - - Unblocks content designers - - Estimated time: 2-3 days +3. **10.1.3 - Expand SimEngine API Tests** (Priority: HIGH, Effort: Medium) + - Why: Improve core system test coverage + - Owner needed: Test-focused agent + - Parallelizable: Test work, independent of infrastructure + - Impact: Better regression detection for core engine + - Estimated time: 2-3 days **Key Risks:** - 🔴 **K8s CI validation missing** - Bad manifests can break deployment (8.3.2) - HIGH IMPACT -- ⚠️ **Phase 8 content pipeline needs ownership** - Task 8.4.1 requires assignment - ⚠️ **Phase 9 LLM enhancement ready** - Rule-based AI complete, LLM-enhanced (9.3.1) unblocked but needs owner +- ✅ **Phase 8 content pipeline complete** - Task 8.4.1 finished with build script, CI workflow, and documentation (2025-12-02) - ✅ **Phase 8 observability complete** - Task 8.3.1 Prometheus annotations and smoke tests added (2025-12-01) - ✅ **Phase 7 delivery risk eliminated** - All core player features complete and tested, per-agent modifiers enabled by default - ✅ **Containerization complete** - Docker/Compose and K8s manifests tested and documented - ✅ **AI player foundation complete** - Observer and action layer shipped with 112 tests -- ✅ **Clean repository state** - Issues #21, #24, #25 closed (verified 2025-12-01) +- ✅ **Clean repository state** - Issues #21, #23, #24, #25 closed (verified 2025-12-02) | ID | Task | Status | Priority | Responsible | Updated | | ----: | ----------------------------------------------- | ----------- | -------- | ------------------ | ---------- | @@ -168,7 +167,7 @@ | 8.3.3 | K8s Resource Sizing & Tuning (M8.3.y) | completed | Medium | devops-agent | 2025-12-02 | | 8.3.3 | Gateway/LLM Prometheus Metrics (M8.3.x) | not-started | Medium | TBD (ask Ross) | 2025-12-01 | | 8.3.4 | Integrate K8s Smoke Test into CI (M8.3.x) | not-started | Medium | TBD (ask Ross) | 2025-12-01 | -| 8.4.1 | Content pipeline tooling & CI (M8.4) | not-started | Medium | TBD (ask Ross) | 2025-11-30 | +| 8.4.1 | Content pipeline tooling & CI (M8.4) | completed | Medium | devops-agent | 2025-12-02 | | 9.1.1 | AI Observer foundation acceptance (M9.1) | completed | Medium | gamedev-agent | 2025-11-30 | | 9.2.1 | Rule-based AI action layer (M9.2) | completed | Medium | gamedev-agent | 2025-12-01 | | 9.3.1 | LLM-enhanced AI decisions (M9.3) | not-started | Medium | TBD (ask Ross) | 2025-11-30 | @@ -717,16 +716,44 @@ ### 8.4.1 — Content Pipeline Tooling & CI (M8.4) - **GitHub Issue:** [#23](https://github.com/TheWizardsCode/GEngine/issues/23) - **Description:** Implement content build tooling (`scripts/build_content.py`), CI validation hooks, and documentation so designers can author/test YAML and story seeds efficiently. -- **Acceptance Criteria:** Content build step produces artifacts consumed by simulation; CI validates content on change; designer workflow documented. +- **Acceptance Criteria:** + - ✅ Content build step produces artifacts consumed by simulation + - ✅ CI validates content on change (schema, references, integrity) + - ✅ Designer workflow documented + - ✅ Clear error messages for content validation failures - **Priority:** Medium -- **Responsible:** TBD (ask Ross) -- **Dependencies:** Stable content schema and directory structure. +- **Responsible:** devops-agent +- **Status:** ✅ COMPLETED +- **Dependencies:** Stable content schema and directory structure (✅ complete). - **Risks & Mitigations:** - Risk: Pipeline friction slows content iteration. Mitigation: Optimize for designer ergonomics, provide quick local commands. -- **Next Steps:** - 1. Implement build script. - 2. Wire into CI. - 3. Document designer workflow. +- **Completion Notes:** + - **Build Script** (`scripts/build_content.py`): + - Validates world definitions (`world.yml` and `story_seeds.yml`) with entity reference checking + - Validates simulation configuration (`simulation.yml`) against Pydantic schema + - Validates difficulty sweep configurations (`content/config/sweeps/*/`) + - Outputs JSON manifest with validation results and file lists + - Clear error messages with icons (❌/✓) and bullet-point formatting + - Exit codes: 0 (success), 1 (validation errors), 2 (file/config errors) + - **CI Workflow** (`.github/workflows/content-validation.yml`): + - Triggers on push to main and PRs that modify content files + - Monitors: `content/**/*.yml`, `content/**/*.yaml`, `scripts/build_content.py`, `.github/workflows/content-*.yml` + - Runs validation via `uv run python scripts/build_content.py --verbose --output content-manifest.json` + - Uploads content manifest artifact for debugging + - Blocks PR merge on validation failures + - **Designer Documentation** (`docs/gengine/content_designer_workflow.md`): + - Content types and structure (worlds, configs, sweeps) + - YAML schema examples with annotations + - Local validation instructions with exit codes + - CI/CD validation details and artifact retrieval + - Troubleshooting section with common validation errors + - Best practices for content authors + - **Test Coverage** (`tests/scripts/test_build_content.py`): + - 17 tests covering all validation paths + - Tests for valid content, missing files, invalid schemas, bad entity references + - Integration tests validating real repository content + - All tests passing +- **Last Updated:** 2025-12-02 ### 9.1.1 — AI Observer Foundation Acceptance (M9.1) - **GitHub Issue:** [#19](https://github.com/TheWizardsCode/GEngine/issues/19) From b3174c0d7b1cbef5cb5fb975d8cd40c4230dd964 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 19:26:58 +0000 Subject: [PATCH 3/3] Fix all ruff linting errors across the codebase Co-authored-by: SorraTheOrc <250240+SorraTheOrc@users.noreply.github.com> --- pyproject.toml | 2 + scripts/analyze_difficulty_profiles.py | 15 +- scripts/eoe_dump_state.py | 1 - scripts/plot_environment_trajectories.py | 6 +- scripts/run_difficulty_sweeps.py | 12 +- scripts/run_headless_sim.py | 36 ++- src/gengine/ai_player/actor.py | 16 +- src/gengine/ai_player/llm_strategy.py | 11 +- src/gengine/ai_player/observer.py | 62 ++--- src/gengine/ai_player/strategies.py | 8 +- src/gengine/echoes/__init__.py | 22 +- src/gengine/echoes/campaign/manager.py | 24 +- src/gengine/echoes/cli/display.py | 242 +++++++++++------- src/gengine/echoes/cli/shell.py | 219 ++++++++++------ src/gengine/echoes/client/service.py | 4 +- src/gengine/echoes/content/loader.py | 29 ++- src/gengine/echoes/core/__init__.py | 4 +- src/gengine/echoes/core/models.py | 5 +- src/gengine/echoes/core/state.py | 4 +- src/gengine/echoes/gateway/app.py | 35 ++- src/gengine/echoes/gateway/client.py | 4 +- src/gengine/echoes/gateway/intent_mapper.py | 23 +- src/gengine/echoes/gateway/llm_client.py | 36 +-- src/gengine/echoes/gateway/session.py | 66 +++-- src/gengine/echoes/llm/anthropic_provider.py | 27 +- src/gengine/echoes/llm/app.py | 12 +- src/gengine/echoes/llm/intents.py | 17 +- src/gengine/echoes/llm/main.py | 8 +- src/gengine/echoes/llm/openai_provider.py | 100 ++++---- src/gengine/echoes/llm/prompts.py | 100 +++++--- src/gengine/echoes/llm/providers.py | 64 +++-- src/gengine/echoes/llm/settings.py | 20 +- src/gengine/echoes/service/app.py | 7 +- src/gengine/echoes/service/main.py | 2 +- src/gengine/echoes/sim/director.py | 94 +++++-- src/gengine/echoes/sim/engine.py | 41 ++- src/gengine/echoes/sim/explanations.py | 59 +++-- src/gengine/echoes/sim/focus.py | 22 +- src/gengine/echoes/sim/post_mortem.py | 72 ++++-- src/gengine/echoes/sim/tick.py | 55 ++-- src/gengine/echoes/systems/__init__.py | 14 +- src/gengine/echoes/systems/agents.py | 10 +- src/gengine/echoes/systems/economy.py | 19 +- src/gengine/echoes/systems/environment.py | 34 ++- src/gengine/echoes/systems/factions.py | 12 +- src/gengine/echoes/systems/progression.py | 19 +- tests/ai_player/test_actor.py | 1 + tests/ai_player/test_observer.py | 10 +- tests/echoes/conftest.py | 4 +- tests/echoes/test_agent_system.py | 31 ++- tests/echoes/test_anthropic_provider.py | 23 +- tests/echoes/test_campaign.py | 5 +- tests/echoes/test_cli_shell.py | 86 ++++--- tests/echoes/test_content_loader.py | 9 +- tests/echoes/test_director_bridge.py | 3 +- tests/echoes/test_display.py | 26 +- tests/echoes/test_environment_system.py | 17 +- tests/echoes/test_explanations.py | 8 +- tests/echoes/test_faction_system.py | 14 +- tests/echoes/test_gateway_client.py | 35 ++- tests/echoes/test_gateway_intent_mapper.py | 4 +- tests/echoes/test_gateway_llm_client.py | 6 +- tests/echoes/test_gateway_service.py | 18 +- tests/echoes/test_llm_prompts.py | 17 +- tests/echoes/test_llm_providers.py | 1 - tests/echoes/test_openai_provider.py | 12 +- tests/echoes/test_post_mortem.py | 20 +- tests/echoes/test_progression.py | 87 ++++--- tests/echoes/test_service_api.py | 10 +- tests/echoes/test_service_client.py | 2 +- tests/echoes/test_settings.py | 11 +- tests/echoes/test_sim_engine.py | 2 +- tests/echoes/test_snapshot_persistence.py | 6 +- tests/echoes/test_story_seeds.py | 4 +- .../test_analyze_difficulty_profiles.py | 10 +- tests/scripts/test_run_difficulty_sweeps.py | 86 +++++-- tests/scripts/test_run_headless_sim.py | 33 ++- 77 files changed, 1448 insertions(+), 847 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 77f1dd0d..aee0f3c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,8 @@ build-backend = "setuptools.build_meta" [tool.ruff] line-length = 88 + +[tool.ruff.lint] select = ["E", "F", "B", "I"] [tool.pytest.ini_options] diff --git a/scripts/analyze_difficulty_profiles.py b/scripts/analyze_difficulty_profiles.py index e611078f..86460cdc 100644 --- a/scripts/analyze_difficulty_profiles.py +++ b/scripts/analyze_difficulty_profiles.py @@ -51,7 +51,9 @@ def from_telemetry(cls, preset: str, data: dict[str, Any]) -> "DifficultyProfile # Calculate faction balance as delta between faction legitimacies faction_leg = data.get("faction_legitimacy", {}) leg_values = list(faction_leg.values()) - faction_balance = max(leg_values) - min(leg_values) if len(leg_values) >= 2 else 0.0 + faction_balance = ( + max(leg_values) - min(leg_values) if len(leg_values) >= 2 else 0.0 + ) # Economic pressure from price volatility economy = data.get("last_economy", {}) @@ -161,9 +163,7 @@ def compare_profiles(profiles: dict[str, DifficultyProfile]) -> dict[str, Any]: "✓ Unrest correctly increases with difficulty (harder = more unrest)" ) else: - findings.append( - "⚠ Unrest does not consistently increase with difficulty" - ) + findings.append("⚠ Unrest does not consistently increase with difficulty") # Check for extreme values for preset, profile in profiles.items(): @@ -171,7 +171,8 @@ def compare_profiles(profiles: dict[str, DifficultyProfile]) -> dict[str, Any]: findings.append(f"⚠ {preset}: Stability collapsed to 0 (may be too harsh)") if profile.anomalies > 100: findings.append( - f"⚠ {preset}: High anomaly count ({profile.anomalies}) indicates system stress" + f"⚠ {preset}: High anomaly count ({profile.anomalies}) " + "indicates system stress" ) # Check differentiation between adjacent difficulties @@ -181,8 +182,8 @@ def compare_profiles(profiles: dict[str, DifficultyProfile]) -> dict[str, Any]: stability_diff = abs(prof1.stability_end - prof2.stability_end) if stability_diff < 0.05: findings.append( - f"⚠ {p1} vs {p2}: Stability difference is minimal ({stability_diff:.3f}), " - "consider widening gap" + f"⚠ {p1} vs {p2}: Stability difference is minimal " + f"({stability_diff:.3f}), consider widening gap" ) comparison["findings"] = findings diff --git a/scripts/eoe_dump_state.py b/scripts/eoe_dump_state.py index 013f73dc..8ffa3595 100644 --- a/scripts/eoe_dump_state.py +++ b/scripts/eoe_dump_state.py @@ -4,7 +4,6 @@ import argparse from pathlib import Path -from typing import Optional from gengine.echoes.content import load_world_bundle from gengine.echoes.persistence import save_snapshot diff --git a/scripts/plot_environment_trajectories.py b/scripts/plot_environment_trajectories.py index 2d10f1f8..9838fb3c 100644 --- a/scripts/plot_environment_trajectories.py +++ b/scripts/plot_environment_trajectories.py @@ -50,7 +50,8 @@ def main(argv: Sequence[str] | None = None) -> int: runs = _collect_runs(args.run) if not runs: raise SystemExit( - "No telemetry files found. Provide --run LABEL=PATH or rerun the sweeps to generate JSON." + "No telemetry files found. Provide --run LABEL=PATH " + "or rerun the sweeps to generate JSON." ) fig, (ax_pollution, ax_unrest) = plt.subplots(2, 1, sharex=True, figsize=(10, 6)) @@ -58,7 +59,8 @@ def main(argv: Sequence[str] | None = None) -> int: ticks, pollution, unrest = _extract_series(path) if len(ticks) < 2: print( - f"Warning: {label} only provided {len(ticks)} sample(s); increase focus.history_length before capturing telemetry." + f"Warning: {label} only provided {len(ticks)} sample(s); " + "increase focus.history_length before capturing telemetry." ) ax_pollution.plot(ticks, pollution, label=label) ax_unrest.plot(ticks, unrest, label=label) diff --git a/scripts/run_difficulty_sweeps.py b/scripts/run_difficulty_sweeps.py index d9746b9c..a852cab3 100644 --- a/scripts/run_difficulty_sweeps.py +++ b/scripts/run_difficulty_sweeps.py @@ -69,10 +69,14 @@ def run_difficulty_sweeps( sys.stderr.write(f"[SKIP] Config not found: {config_root}\n") continue - output_path = output_dir / f"difficulty-{preset}-sweep.json" if output_dir else None + output_path = ( + output_dir / f"difficulty-{preset}-sweep.json" if output_dir else None + ) if verbose: - sys.stderr.write(f"\n[START] {preset.upper()} difficulty ({ticks} ticks, seed={seed})\n") + sys.stderr.write( + f"\n[START] {preset.upper()} difficulty ({ticks} ticks, seed={seed})\n" + ) start = perf_counter() summary = run_headless_sim( @@ -106,7 +110,9 @@ def run_difficulty_sweeps( total_elapsed = perf_counter() - start_total if verbose: - sys.stderr.write(f"\n[COMPLETE] {len(results)} presets in {total_elapsed:.1f}s\n") + sys.stderr.write( + f"\n[COMPLETE] {len(results)} presets in {total_elapsed:.1f}s\n" + ) return results diff --git a/scripts/run_headless_sim.py b/scripts/run_headless_sim.py index 75d1b5ff..98a52bb6 100644 --- a/scripts/run_headless_sim.py +++ b/scripts/run_headless_sim.py @@ -58,14 +58,26 @@ def run_headless_sim( "faction_actions": sum(len(report.faction_actions) for report in reports), "faction_action_breakdown": _faction_breakdown(reports), } - summary["suppressed_events"] = sum(len(report.suppressed_events) for report in reports) + summary["suppressed_events"] = sum( + len(report.suppressed_events) for report in reports + ) summary["director_feed"] = dict(engine.state.metadata.get("director_feed", {})) - summary["director_history"] = list(engine.state.metadata.get("director_history") or []) - summary["director_analysis"] = dict(engine.state.metadata.get("director_analysis") or {}) - summary["director_events"] = list(engine.state.metadata.get("director_events") or []) - summary["director_pacing"] = dict(engine.state.metadata.get("director_pacing") or {}) + summary["director_history"] = list( + engine.state.metadata.get("director_history") or [] + ) + summary["director_analysis"] = dict( + engine.state.metadata.get("director_analysis") or {} + ) + summary["director_events"] = list( + engine.state.metadata.get("director_events") or [] + ) + summary["director_pacing"] = dict( + engine.state.metadata.get("director_pacing") or {} + ) summary["story_seeds"] = list(engine.state.metadata.get("story_seeds_active") or []) - summary["story_seed_lifecycle"] = dict(engine.state.metadata.get("story_seed_lifecycle") or {}) + summary["story_seed_lifecycle"] = dict( + engine.state.metadata.get("story_seed_lifecycle") or {} + ) summary["story_seed_lifecycle_history"] = list( engine.state.metadata.get("story_seed_lifecycle_history") or [] ) @@ -131,7 +143,9 @@ def _advance_in_batches( "ticks": len(step_reports), "ending_tick": last_report.tick if last_report else engine.state.tick, "agent_actions": sum(len(report.agent_actions) for report in step_reports), - "faction_actions": sum(len(report.faction_actions) for report in step_reports), + "faction_actions": sum( + len(report.faction_actions) for report in step_reports + ), } if last_report is not None: batch_payload["tick_ms"] = round( @@ -213,8 +227,12 @@ def main(argv: Sequence[str] | None = None) -> int: default=None, help="Optional snapshot file to load instead of content", ) - parser.add_argument("--ticks", "-t", type=int, default=200, help="Number of ticks to advance") - parser.add_argument("--seed", type=int, default=None, help="RNG seed override for determinism") + parser.add_argument( + "--ticks", "-t", type=int, default=200, help="Number of ticks to advance" + ) + parser.add_argument( + "--seed", type=int, default=None, help="RNG seed override for determinism" + ) parser.add_argument( "--lod", choices=["detailed", "balanced", "coarse"], diff --git a/src/gengine/ai_player/actor.py b/src/gengine/ai_player/actor.py index 65374682..0edf32d6 100644 --- a/src/gengine/ai_player/actor.py +++ b/src/gengine/ai_player/actor.py @@ -419,9 +419,9 @@ def _create_observation_summary( start_value=1.0, # Assumed start end_value=stability, delta=stability - 1.0, - trend="stable" if abs(stability - 1.0) < 0.01 else ( - "increasing" if stability > 1.0 else "decreasing" - ), + trend="stable" + if abs(stability - 1.0) < 0.01 + else ("increasing" if stability > 1.0 else "decreasing"), ) # Extract faction swings @@ -432,9 +432,9 @@ def _create_observation_summary( start_value=0.5, # Assumed start end_value=leg, delta=leg - 0.5, - trend="stable" if abs(leg - 0.5) < 0.05 else ( - "increasing" if leg > 0.5 else "decreasing" - ), + trend="stable" + if abs(leg - 0.5) < 0.05 + else ("increasing" if leg > 0.5 else "decreasing"), ) return ObservationReport( @@ -472,9 +472,7 @@ def _build_telemetry(self, final_state: dict[str, Any]) -> dict[str, Any]: return { "action_counts": action_counts, - "priority_stats": { - k: round(v, 4) for k, v in priority_stats.items() - }, + "priority_stats": {k: round(v, 4) for k, v in priority_stats.items()}, "strategy_type": self._strategy.strategy_type.value, "final_state": { "stability": final_state.get("stability", 1.0), diff --git a/src/gengine/ai_player/llm_strategy.py b/src/gengine/ai_player/llm_strategy.py index 9940a100..71696c5b 100644 --- a/src/gengine/ai_player/llm_strategy.py +++ b/src/gengine/ai_player/llm_strategy.py @@ -83,9 +83,7 @@ def __post_init__(self) -> None: if self.llm_timeout_seconds <= 0: raise ValueError("llm_timeout_seconds must be positive") if not 0.0 <= self.rule_priority_scaling <= 1.0: - raise ValueError( - "rule_priority_scaling must be between 0.0 and 1.0" - ) + raise ValueError("rule_priority_scaling must be between 0.0 and 1.0") @dataclass @@ -270,6 +268,7 @@ def request_decision( if loop is not None and loop.is_running(): # Already in async context - use thread to avoid nested loops import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: # Create a new event loop in the thread future = executor.submit(self._run_in_new_loop, request) @@ -376,7 +375,8 @@ def _build_command_from_context( if "multiple_stressed_factions" in factors: factions = request.state.get("faction_legitimacy", {}) low_factions = [ - f for f, leg in factions.items() + f + for f, leg in factions.items() if leg < self._config.complexity_threshold_legitimacy ] return ( @@ -496,7 +496,8 @@ def evaluate_complexity( # Check faction stress faction_legitimacy = state.get("faction_legitimacy", {}) stressed_factions = sum( - 1 for leg in faction_legitimacy.values() + 1 + for leg in faction_legitimacy.values() if leg < config.complexity_threshold_legitimacy ) if stressed_factions >= config.complexity_threshold_factions: diff --git a/src/gengine/ai_player/observer.py b/src/gengine/ai_player/observer.py index 37a27fc9..f7211619 100644 --- a/src/gengine/ai_player/observer.py +++ b/src/gengine/ai_player/observer.py @@ -210,12 +210,14 @@ def observe(self, ticks: int | None = None) -> ObservationReport: self._check_story_seeds(state, story_seeds_activated) self._check_alerts(state, alerts, commentary) - tick_reports.append({ - "tick": state.get("tick", 0), - "stability": state.get("stability", 1.0), - "faction_legitimacy": dict(state.get("faction_legitimacy", {})), - "story_seeds": list(state.get("story_seeds", [])), - }) + tick_reports.append( + { + "tick": state.get("tick", 0), + "stability": state.get("stability", 1.0), + "faction_legitimacy": dict(state.get("faction_legitimacy", {})), + "story_seeds": list(state.get("story_seeds", [])), + } + ) final_state = self._get_state() end_tick = final_state.get("tick", start_tick + ticks_advanced) @@ -236,11 +238,13 @@ def observe(self, ticks: int | None = None) -> ObservationReport: ) faction_swings[fid] = trend - commentary.extend(self._generate_commentary( - stability_trend, - faction_swings, - story_seeds_activated, - )) + commentary.extend( + self._generate_commentary( + stability_trend, + faction_swings, + story_seeds_activated, + ) + ) return ObservationReport( ticks_observed=ticks_advanced, @@ -257,10 +261,10 @@ def observe(self, ticks: int | None = None) -> ObservationReport: def _get_state(self) -> dict[str, Any]: """Fetch current simulation state. - + For remote connections, will raise an exception if connection fails. Callers should handle connection errors appropriately. - + Returns ------- dict @@ -287,12 +291,12 @@ def _get_state(self) -> dict[str, Any]: def _advance_ticks(self, count: int) -> dict[str, Any]: """Advance simulation by count ticks. - + Parameters ---------- count Number of ticks to advance. - + Returns ------- dict @@ -309,9 +313,7 @@ def _advance_ticks(self, count: int) -> dict[str, Any]: return self._client.tick(count) except Exception as e: logger.error(f"Failed to advance ticks on remote service: {e}") - raise ConnectionError( - f"Unable to advance simulation: {e}" - ) from e + raise ConnectionError(f"Unable to advance simulation: {e}") from e def _analyze_trend( self, @@ -381,11 +383,13 @@ def _check_story_seeds( for seed in current_seeds: seed_id = seed.get("seed_id") or seed.get("id") if seed_id and seed_id not in known_ids: - activated.append({ - "seed_id": seed_id, - "tick": state.get("tick", 0), - "district": seed.get("target_district") or seed.get("district"), - }) + activated.append( + { + "seed_id": seed_id, + "tick": state.get("tick", 0), + "district": seed.get("target_district") or seed.get("district"), + } + ) def _check_alerts( self, @@ -413,7 +417,7 @@ def _check_alerts( def _extract_environment(self, state: dict[str, Any]) -> dict[str, float]: """Extract environment and system metrics from state. - + Captures stability, environment impact, economy metrics, and agent counts to provide a comprehensive view of simulation health. """ @@ -422,27 +426,27 @@ def _extract_environment(self, state: dict[str, Any]) -> dict[str, float]: for key in ["stability", "unrest", "pollution", "biodiversity", "security"]: if key in state: env[key] = state[key] - + # Environment impact metrics env_impact = state.get("environment_impact", {}) if isinstance(env_impact, dict): for key in ["avg_pollution", "biodiversity", "scarcity_pressure"]: if key in env_impact: env[f"impact_{key}"] = env_impact[key] - + # Economy metrics economy = state.get("economy", {}) if isinstance(economy, dict): for key in ["wealth_ratio", "supply_demand_ratio", "avg_capacity"]: if key in economy: env[f"economy_{key}"] = economy[key] - + # Agent system metrics if "agent_count" in state: env["agent_count"] = state["agent_count"] if "agent_satisfaction_avg" in state: env["agent_satisfaction_avg"] = state["agent_satisfaction_avg"] - + return env def _generate_commentary( @@ -452,7 +456,7 @@ def _generate_commentary( story_seeds: list[dict[str, Any]], ) -> list[str]: """Generate structured natural language commentary on the observation. - + Produces human-readable analysis of stability trends, faction dynamics, and narrative events with varying detail based on magnitude of changes. """ diff --git a/src/gengine/ai_player/strategies.py b/src/gengine/ai_player/strategies.py index 126304ff..e294bb84 100644 --- a/src/gengine/ai_player/strategies.py +++ b/src/gengine/ai_player/strategies.py @@ -202,7 +202,7 @@ def _find_district_needing_resources( state: dict[str, Any], ) -> str | None: """Find a district that needs resource deployment. - + Note: The summary API returns district count, not district list. This method returns a placeholder district ID when no detailed district data is available. @@ -338,8 +338,7 @@ def evaluate( intent=intent, priority=0.7, rationale=( - f"Deploying resources to {district} " - "for stabilization" + f"Deploying resources to {district} for stabilization" ), strategy_type=self.strategy_type, tick=tick, @@ -530,8 +529,7 @@ def evaluate( intent=intent, priority=0.7, rationale=( - f"Pressuring faction {faction_id} " - f"at {legitimacy:.2f}" + f"Pressuring faction {faction_id} at {legitimacy:.2f}" ), strategy_type=self.strategy_type, tick=tick, diff --git a/src/gengine/echoes/__init__.py b/src/gengine/echoes/__init__.py index c98e7ffe..e7c6e9c0 100644 --- a/src/gengine/echoes/__init__.py +++ b/src/gengine/echoes/__init__.py @@ -8,15 +8,15 @@ from .systems import AgentSystem, EconomySystem, FactionSystem __all__ = [ - "GameState", - "SimEngine", - "TickReport", - "advance_ticks", - "SimServiceClient", - "create_service_app", - "AgentSystem", - "FactionSystem", - "EconomySystem", - "SimulationConfig", - "load_simulation_config", + "GameState", + "SimEngine", + "TickReport", + "advance_ticks", + "SimServiceClient", + "create_service_app", + "AgentSystem", + "FactionSystem", + "EconomySystem", + "SimulationConfig", + "load_simulation_config", ] diff --git a/src/gengine/echoes/campaign/manager.py b/src/gengine/echoes/campaign/manager.py index 78eec30b..508bc411 100644 --- a/src/gengine/echoes/campaign/manager.py +++ b/src/gengine/echoes/campaign/manager.py @@ -11,11 +11,11 @@ from __future__ import annotations import json +import uuid from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional -import uuid def _json_default(value: Any) -> Any: @@ -191,7 +191,8 @@ def create_campaign( metadata_path = campaign_dir / self.METADATA_FILENAME metadata_path.write_text( - json.dumps(campaign.to_dict(), indent=2, default=_json_default), encoding="utf-8" + json.dumps(campaign.to_dict(), indent=2, default=_json_default), + encoding="utf-8", ) self._active_campaign = campaign @@ -309,13 +310,17 @@ def save_campaign( # Save metadata metadata_path = campaign_dir / self.METADATA_FILENAME metadata_path.write_text( - json.dumps(self._active_campaign.to_dict(), indent=2, default=_json_default), encoding="utf-8" + json.dumps( + self._active_campaign.to_dict(), indent=2, default=_json_default + ), + encoding="utf-8", ) # Save snapshot snapshot_path = campaign_dir / self.SNAPSHOT_FILENAME snapshot_path.write_text( - json.dumps(state_snapshot, indent=2, default=_json_default), encoding="utf-8" + json.dumps(state_snapshot, indent=2, default=_json_default), + encoding="utf-8", ) return snapshot_path @@ -354,7 +359,8 @@ def check_autosave( autosave_path = campaign_dir / autosave_name autosave_path.write_text( - json.dumps(state_snapshot, indent=2, default=_json_default), encoding="utf-8" + json.dumps(state_snapshot, indent=2, default=_json_default), + encoding="utf-8", ) self._last_autosave_tick = tick @@ -449,13 +455,17 @@ def end_campaign( if post_mortem and self._settings.generate_postmortem_on_end: postmortem_path = campaign_dir / self.POSTMORTEM_FILENAME postmortem_path.write_text( - json.dumps(post_mortem, indent=2, default=_json_default), encoding="utf-8" + json.dumps(post_mortem, indent=2, default=_json_default), + encoding="utf-8", ) # Update metadata metadata_path = campaign_dir / self.METADATA_FILENAME metadata_path.write_text( - json.dumps(self._active_campaign.to_dict(), indent=2, default=_json_default), encoding="utf-8" + json.dumps( + self._active_campaign.to_dict(), indent=2, default=_json_default + ), + encoding="utf-8", ) ended_campaign = self._active_campaign diff --git a/src/gengine/echoes/cli/display.py b/src/gengine/echoes/cli/display.py index 3188277b..6f0bc2b3 100644 --- a/src/gengine/echoes/cli/display.py +++ b/src/gengine/echoes/cli/display.py @@ -12,88 +12,109 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table -from rich.text import Text def render_summary_table(summary: dict[str, Any]) -> str: """Render world summary as a formatted table with panels.""" console = Console(file=StringIO(), force_terminal=True, width=80) - + # Core stats table - stats_table = Table(title="World Status", show_header=True, header_style="bold cyan") + stats_table = Table( + title="World Status", show_header=True, header_style="bold cyan" + ) stats_table.add_column("Metric", style="cyan", width=15) stats_table.add_column("Value", style="yellow", width=20) - + for key in ("city", "tick", "districts", "factions", "agents"): stats_table.add_row(key.capitalize(), str(summary.get(key, "N/A"))) - + stability = summary.get("stability") if isinstance(stability, (int, float)): - stab_color = "green" if stability >= 0.7 else "yellow" if stability >= 0.4 else "red" - stats_table.add_row("Stability", f"[{stab_color}]{stability:.3f}[/{stab_color}]") - + stab_color = ( + "green" if stability >= 0.7 else "yellow" if stability >= 0.4 else "red" + ) + stats_table.add_row( + "Stability", f"[{stab_color}]{stability:.3f}[/{stab_color}]" + ) + console.print(stats_table) console.print() - + # Environment impact panel impact = summary.get("environment_impact") if isinstance(impact, dict) and impact: _render_environment_panel(console, impact) console.print() - + # Focus state panel focus = summary.get("focus") if isinstance(focus, dict) and focus.get("district_id"): _render_focus_panel(console, focus) console.print() - + # Focus digest panel digest = summary.get("focus_digest") if isinstance(digest, dict) and digest.get("visible"): _render_digest_panel(console, digest) console.print() - + # Story seeds panel seeds = summary.get("story_seeds") if isinstance(seeds, list) and seeds: _render_story_seeds_panel(console, seeds) console.print() - + # Profiling panel profiling = summary.get("profiling") if isinstance(profiling, dict) and profiling: _render_profiling_panel(console, profiling) - + return console.file.getvalue() def _render_environment_panel(console: Console, impact: dict[str, Any]) -> None: """Render environment impact as a styled panel.""" lines = [] - + pressure = impact.get("scarcity_pressure", 0.0) - pressure_color = "red" if pressure > 2.0 else "yellow" if pressure > 1.0 else "green" - lines.append(f"[bold]Scarcity Pressure:[/bold] [{pressure_color}]{pressure:.2f}[/{pressure_color}]") - + pressure_color = ( + "red" if pressure > 2.0 else "yellow" if pressure > 1.0 else "green" + ) + lines.append( + f"[bold]Scarcity Pressure:[/bold] " + f"[{pressure_color}]{pressure:.2f}[/{pressure_color}]" + ) + if impact.get("diffusion_applied"): lines.append("[dim]Diffusion: active[/dim]") - + avg_pollution = impact.get("average_pollution") if isinstance(avg_pollution, (int, float)): - poll_color = "red" if avg_pollution > 0.7 else "yellow" if avg_pollution > 0.4 else "green" - lines.append(f"[bold]Avg Pollution:[/bold] [{poll_color}]{avg_pollution:.3f}[/{poll_color}]") - + poll_color = ( + "red" + if avg_pollution > 0.7 + else "yellow" + if avg_pollution > 0.4 + else "green" + ) + lines.append( + f"[bold]Avg Pollution:[/bold] " + f"[{poll_color}]{avg_pollution:.3f}[/{poll_color}]" + ) + extremes = impact.get("extremes") or {} if isinstance(extremes, dict) and extremes: max_info = extremes.get("max") or {} min_info = extremes.get("min") or {} if max_info and min_info: + max_poll = float(max_info.get("pollution", 0.0)) + min_poll = float(min_info.get("pollution", 0.0)) lines.append( f"[bold]Extremes:[/bold] " - f"max [red]{max_info.get('district')}[/red] {float(max_info.get('pollution', 0.0)):.3f}, " - f"min [green]{min_info.get('district')}[/green] {float(min_info.get('pollution', 0.0)):.3f}" + f"max [red]{max_info.get('district')}[/red] {max_poll:.3f}, " + f"min [green]{min_info.get('district')}[/green] {min_poll:.3f}" ) - + biodiversity = impact.get("biodiversity") or {} if isinstance(biodiversity, dict) and biodiversity.get("value") is not None: value = biodiversity.get("value") @@ -101,35 +122,42 @@ def _render_environment_panel(console: Console, impact: dict[str, Any]) -> None: bio_color = "green" if value > 0.7 else "yellow" if value > 0.5 else "red" delta = biodiversity.get("delta", 0.0) delta_text = f" ({delta:+.3f})" if abs(delta) >= 1e-4 else "" - lines.append(f"[bold]Biodiversity:[/bold] [{bio_color}]{value:.3f}{delta_text}[/{bio_color}]") - - panel = Panel("\n".join(lines), title="[bold cyan]Environment Impact[/bold cyan]", border_style="cyan") + lines.append( + f"[bold]Biodiversity:[/bold] " + f"[{bio_color}]{value:.3f}{delta_text}[/{bio_color}]" + ) + + panel = Panel( + "\n".join(lines), + title="[bold cyan]Environment Impact[/bold cyan]", + border_style="cyan", + ) console.print(panel) def _render_focus_panel(console: Console, focus: dict[str, Any]) -> None: """Render focus state as a styled panel.""" lines = [] - + district_id = focus.get("district_id", "") lines.append(f"[bold yellow]Center:[/bold yellow] {district_id}") - + neighbors = focus.get("neighbors") or [] if neighbors: lines.append(f"[bold]Neighbors:[/bold] {', '.join(neighbors)}") - + coords = focus.get("coordinates") if coords: coord_str = f"({coords.get('x', 0):.1f}, {coords.get('y', 0):.1f}" - if coords.get('z') is not None: + if coords.get("z") is not None: coord_str += f", {coords.get('z', 0):.1f}" coord_str += ")" lines.append(f"[bold]Coords:[/bold] {coord_str}") - + adjacent = focus.get("adjacent") or [] if adjacent: lines.append(f"[dim]Adjacent:[/dim] {', '.join(adjacent)}") - + weights = focus.get("spatial_weights") or [] if weights: weight_strs = [ @@ -137,69 +165,77 @@ def _render_focus_panel(console: Console, focus: dict[str, Any]) -> None: for entry in weights[:3] ] lines.append(f"[bold]Spatial Weights:[/bold] {', '.join(weight_strs)}") - - panel = Panel("\n".join(lines), title="[bold magenta]Focus State[/bold magenta]", border_style="magenta") + + panel = Panel( + "\n".join(lines), + title="[bold magenta]Focus State[/bold magenta]", + border_style="magenta", + ) console.print(panel) def _render_digest_panel(console: Console, digest: dict[str, Any]) -> None: """Render focus digest as a styled panel.""" lines = [] - + visible = digest.get("visible", []) for i, event in enumerate(visible[:3], 1): lines.append(f"{i}. {event}") - + suppressed = digest.get("suppressed_count", 0) if suppressed: lines.append(f"\n[dim italic]({suppressed} archived events)[/dim italic]") - + ranked = digest.get("ranked_archive") or [] if ranked and len(ranked) > 0: - lines.append(f"\n[bold]Top Ranked:[/bold]") + lines.append("\n[bold]Top Ranked:[/bold]") for entry in ranked[:2]: score = entry.get("score", 0.0) message = entry.get("message", "") lines.append(f" • [{score:.2f}] {message}") - - panel = Panel("\n".join(lines), title="[bold green]Focus Digest[/bold green]", border_style="green") + + panel = Panel( + "\n".join(lines), + title="[bold green]Focus Digest[/bold green]", + border_style="green", + ) console.print(panel) def _render_story_seeds_panel(console: Console, seeds: list[dict[str, Any]]) -> None: """Render active story seeds as a styled table.""" - table = Table(title="Active Story Seeds", show_header=True, header_style="bold yellow") + table = Table( + title="Active Story Seeds", show_header=True, header_style="bold yellow" + ) table.add_column("Seed", style="yellow", width=25) table.add_column("Title", style="white", width=30) table.add_column("Status", style="cyan", width=15) - + for seed in seeds[:5]: seed_id = seed.get("seed_id", "unknown") title = seed.get("title", "Untitled") cooldown = seed.get("cooldown_remaining", 0) status = f"cooling ({cooldown})" if cooldown > 0 else "active" status_color = "yellow" if cooldown > 0 else "green" - - table.add_row( - seed_id, - title, - f"[{status_color}]{status}[/{status_color}]" - ) - + + table.add_row(seed_id, title, f"[{status_color}]{status}[/{status_color}]") + console.print(table) def _render_profiling_panel(console: Console, profiling: dict[str, Any]) -> None: """Render profiling metrics as a styled table.""" - table = Table(title="Performance Metrics", show_header=True, header_style="bold blue") + table = Table( + title="Performance Metrics", show_header=True, header_style="bold blue" + ) table.add_column("Metric", style="cyan", width=25) table.add_column("Value", style="yellow", width=15) - + for key in ("tick_ms_p50", "tick_ms_p95", "tick_ms_max"): value = profiling.get(key) if value is not None: table.add_row(key, f"{value:.2f}ms") - + slowest = profiling.get("slowest_subsystems") or [] if slowest: table.add_row("", "") # Spacer @@ -208,95 +244,106 @@ def _render_profiling_panel(console: Console, profiling: dict[str, Any]) -> None name = entry.get("name", "") ms = entry.get("ms", 0.0) table.add_row(f" {name}", f"{ms:.2f}ms") - + anomalies = profiling.get("anomaly_count", 0) if anomalies > 0: table.add_row("", "") # Spacer anom_color = "red" if anomalies > 100 else "yellow" - table.add_row("[bold]Anomalies[/bold]", f"[{anom_color}]{anomalies}[/{anom_color}]") - + table.add_row( + "[bold]Anomalies[/bold]", f"[{anom_color}]{anomalies}[/{anom_color}]" + ) + console.print(table) -def render_director_table(feed: dict[str, Any], history: list[dict[str, Any]] | None, analysis: dict[str, Any] | None) -> str: +def render_director_table( + feed: dict[str, Any], + history: list[dict[str, Any]] | None, + analysis: dict[str, Any] | None, +) -> str: """Render director feed and analysis as formatted tables.""" console = Console(file=StringIO(), force_terminal=True, width=80) - + # Current feed panel if feed: lines = [] lines.append(f"[bold]Tick:[/bold] {feed.get('tick', 0)}") - - focus_center = feed.get('focus_center') + + focus_center = feed.get("focus_center") if focus_center: lines.append(f"[bold]Focus:[/bold] {focus_center}") - - suppressed = feed.get('suppressed_count', 0) + + suppressed = feed.get("suppressed_count", 0) if suppressed: lines.append(f"[dim]Suppressed: {suppressed}[/dim]") - - panel = Panel("\n".join(lines), title="[bold cyan]Current Director Feed[/bold cyan]", border_style="cyan") + + panel = Panel( + "\n".join(lines), + title="[bold cyan]Current Director Feed[/bold cyan]", + border_style="cyan", + ) console.print(panel) console.print() - + # Director events table if analysis: events = analysis.get("director_events") or [] if events: - table = Table(title="Director Events", show_header=True, header_style="bold yellow") + table = Table( + title="Director Events", show_header=True, header_style="bold yellow" + ) table.add_column("Seed", style="yellow", width=20) table.add_column("Title", style="white", width=25) table.add_column("District", style="cyan", width=20) table.add_column("Tick", style="dim", width=8) - + for event in events[:5]: table.add_row( event.get("seed_id", ""), event.get("title", ""), event.get("district_id", ""), - str(event.get("tick", 0)) + str(event.get("tick", 0)), ) - + console.print(table) console.print() - + # History table if history: - table = Table(title="Focus History", show_header=True, header_style="bold magenta") + table = Table( + title="Focus History", show_header=True, header_style="bold magenta" + ) table.add_column("Tick", style="dim", width=8) table.add_column("Center", style="cyan", width=20) table.add_column("Suppressed", style="yellow", width=12) table.add_column("Top Events", style="white", width=30) - + for entry in history[-5:]: tick = entry.get("tick", 0) center = entry.get("focus_center", "") suppressed = entry.get("suppressed_count", 0) - + top_ranked = entry.get("top_ranked") or [] top_msg = top_ranked[0].get("message", "") if top_ranked else "" if len(top_msg) > 27: top_msg = top_msg[:27] + "..." - - table.add_row( - str(tick), - center, - str(suppressed), - top_msg - ) - + + table.add_row(str(tick), center, str(suppressed), top_msg) + console.print(table) - + return console.file.getvalue() def render_map_overlay(summary: dict[str, Any]) -> str: """Render enhanced map with district overlays in a styled format.""" console = Console(file=StringIO(), force_terminal=True, width=100) - - console.print(Panel("[bold cyan]City Map Overview[/bold cyan]", border_style="cyan")) + + console.print( + Panel("[bold cyan]City Map Overview[/bold cyan]", border_style="cyan") + ) console.print() - + # District status table table = Table(show_header=True, header_style="bold cyan") table.add_column("District", style="cyan", width=25) @@ -304,7 +351,7 @@ def render_map_overlay(summary: dict[str, Any]) -> str: table.add_column("Pollution", style="white", width=12) table.add_column("Unrest", style="white", width=12) table.add_column("Status", style="green", width=15) - + # Note: This is a placeholder - real implementation would extract district data # from the full state snapshot table.add_row( @@ -312,35 +359,40 @@ def render_map_overlay(summary: dict[str, Any]) -> str: "120,000", "[red]0.85[/red]", "[yellow]0.45[/yellow]", - "[green]Stable[/green]" + "[green]Stable[/green]", ) table.add_row( "Research Spire", "45,000", "[green]0.25[/green]", "[green]0.15[/green]", - "[green]Stable[/green]" + "[green]Stable[/green]", ) table.add_row( "Perimeter Hollow", "200,000", "[yellow]0.55[/yellow]", "[red]0.75[/red]", - "[yellow]Tense[/yellow]" + "[yellow]Tense[/yellow]", ) - + console.print(table) console.print() - + # Geometry overlay info focus = summary.get("focus") if isinstance(focus, dict) and focus.get("district_id"): coords = focus.get("coordinates") if coords: - console.print(f"[bold]Focus Center:[/bold] {focus.get('district_id')} at ({coords.get('x', 0):.1f}, {coords.get('y', 0):.1f})") - + x_coord = coords.get("x", 0) + y_coord = coords.get("y", 0) + console.print( + f"[bold]Focus Center:[/bold] {focus.get('district_id')} " + f"at ({x_coord:.1f}, {y_coord:.1f})" + ) + adjacent = focus.get("adjacent") or [] if adjacent: console.print(f"[dim]Adjacent districts: {', '.join(adjacent)}[/dim]") - + return console.file.getvalue() diff --git a/src/gengine/echoes/cli/shell.py b/src/gengine/echoes/cli/shell.py index e76c5ab0..bf03e212 100644 --- a/src/gengine/echoes/cli/shell.py +++ b/src/gengine/echoes/cli/shell.py @@ -9,17 +9,25 @@ from pathlib import Path from typing import Any, Iterable, List, Mapping, Optional, Sequence -from ..campaign import Campaign, CampaignManager, CampaignSettings as CampaignMgrSettings +from ..campaign import CampaignManager +from ..campaign import CampaignSettings as CampaignMgrSettings +from ..client import SimServiceClient from ..core import GameState from ..persistence import save_snapshot -from ..client import SimServiceClient -from ..settings import SimulationConfig, SimulationLimits, CampaignSettings, load_simulation_config +from ..settings import ( + SimulationConfig, + SimulationLimits, + load_simulation_config, +) from ..sim import SimEngine, TickReport try: - from . import display from io import StringIO + from rich.console import Console + + from . import display + RICH_AVAILABLE = True except ImportError: RICH_AVAILABLE = False @@ -75,7 +83,9 @@ def load_snapshot(self, path: Path) -> str: # pragma: no cover def focus_state(self) -> dict[str, object]: # pragma: no cover raise NotImplementedError - def set_focus(self, district_id: str | None) -> dict[str, object]: # pragma: no cover + def set_focus( + self, district_id: str | None + ) -> dict[str, object]: # pragma: no cover raise NotImplementedError def focus_history(self) -> List[dict[str, object]]: # pragma: no cover @@ -84,10 +94,14 @@ def focus_history(self) -> List[dict[str, object]]: # pragma: no cover def post_mortem(self) -> dict[str, object]: # pragma: no cover raise NotImplementedError - def query_timeline(self, count: int = 10) -> List[dict[str, object]]: # pragma: no cover + def query_timeline( + self, count: int = 10 + ) -> List[dict[str, object]]: # pragma: no cover raise NotImplementedError - def explain(self, entity_type: str, entity_id: str) -> dict[str, object]: # pragma: no cover + def explain( + self, entity_type: str, entity_id: str + ) -> dict[str, object]: # pragma: no cover raise NotImplementedError def why(self, query: str) -> dict[str, object]: # pragma: no cover @@ -108,7 +122,9 @@ def campaign_new( """Create a new campaign.""" raise NotImplementedError - def campaign_resume(self, campaign_id: str) -> dict[str, object]: # pragma: no cover + def campaign_resume( + self, campaign_id: str + ) -> dict[str, object]: # pragma: no cover """Resume an existing campaign.""" raise NotImplementedError @@ -219,9 +235,7 @@ def campaign_new( name=name, world=world, description=description ) # Save initial snapshot - self._campaign_manager.save_campaign( - self.state.snapshot(), self.state.tick - ) + self._campaign_manager.save_campaign(self.state.snapshot(), self.state.tick) return campaign.to_dict() def campaign_resume(self, campaign_id: str) -> dict[str, object]: @@ -436,9 +450,7 @@ def _cmd_run(self, args: Sequence[str]) -> CommandResult: reports = self.backend.advance_ticks(capped) output = _render_reports(reports) if capped < count: - prefix = ( - f"Safeguard: run limited to {limit} ticks (requested {count})." - ) + prefix = f"Safeguard: run limited to {limit} ticks (requested {count})." output = f"{prefix}\n{output}" if output else prefix return CommandResult(output) @@ -499,7 +511,9 @@ def _cmd_postmortem(self, args: Sequence[str]) -> CommandResult: return CommandResult("Usage: postmortem") payload = self.backend.post_mortem() if not payload: - return CommandResult("Post-mortem summary unavailable; run a few ticks first.") + return CommandResult( + "Post-mortem summary unavailable; run a few ticks first." + ) return CommandResult(_render_post_mortem(payload)) def _cmd_timeline(self, args: Sequence[str]) -> CommandResult: @@ -520,8 +534,7 @@ def _cmd_timeline(self, args: Sequence[str]) -> CommandResult: def _cmd_explain(self, args: Sequence[str]) -> CommandResult: if len(args) < 2: return CommandResult( - "Usage: explain \n" - "Types: faction, agent, district, metric" + "Usage: explain \nTypes: faction, agent, district, metric" ) entity_type = args[0].lower() entity_id = args[1] @@ -711,27 +724,24 @@ def _render_summary(summary: dict[str, object]) -> str: max_info = extremes.get("max") or {} min_info = extremes.get("min") or {} if max_info and min_info: + max_poll = float(max_info.get("pollution", 0.0)) + min_poll = float(min_info.get("pollution", 0.0)) lines.append( " extremes: " - f"max {max_info.get('district')} {float(max_info.get('pollution', 0.0)):.3f}" - ", " - f"min {min_info.get('district')} {float(min_info.get('pollution', 0.0)):.3f}" + f"max {max_info.get('district')} {max_poll:.3f}, " + f"min {min_info.get('district')} {min_poll:.3f}" ) deltas = impact.get("district_deltas") or {} if deltas: sample_id, sample_delta = next(iter(deltas.items())) poll_delta = sample_delta.get("pollution", 0.0) - lines.append( - f" sample delta: {sample_id} pollution {poll_delta:+.3f}" - ) + lines.append(f" sample delta: {sample_id} pollution {poll_delta:+.3f}") diffusion_samples = impact.get("diffusion_samples") or [] if diffusion_samples: preview = [] for sample in diffusion_samples[:2]: delta = float(sample.get("delta", 0.0)) - preview.append( - f"{sample.get('district_id')}: {delta:+.3f}" - ) + preview.append(f"{sample.get('district_id')}: {delta:+.3f}") if preview: lines.append(f" diffusion samples: {', '.join(preview)}") biodiversity = impact.get("biodiversity") or {} @@ -745,23 +755,25 @@ def _render_summary(summary: dict[str, object]) -> str: lines.append(f" biodiversity: {value:.3f}{delta_text}") stab = impact.get("stability_effects") or {} stability_delta = stab.get("biodiversity_delta") - if isinstance(stability_delta, (int, float)) and abs(stability_delta) >= 1e-4: + if ( + isinstance(stability_delta, (int, float)) + and abs(stability_delta) >= 1e-4 + ): lines.append(f" stability<-bio: {stability_delta:+.3f}") faction_effects = impact.get("faction_effects") or [] if faction_effects: preview = [] for effect in faction_effects[:2]: + delta = effect["pollution_delta"] preview.append( - f"{effect['faction']}->{effect['district']} ({effect['pollution_delta']:+.3f})" + f"{effect['faction']}->{effect['district']} ({delta:+.3f})" ) lines.append(f" faction effects: {', '.join(preview)}") focus = summary.get("focus") if isinstance(focus, dict) and focus.get("district_id"): neighbors = focus.get("neighbors") or [] neighbor_text = ", ".join(neighbors) if neighbors else "none" - lines.append( - f" focus -> {focus['district_id']} (neighbors: {neighbor_text})" - ) + lines.append(f" focus -> {focus['district_id']} (neighbors: {neighbor_text})") coords = focus.get("coordinates") if coords: lines.append(f" coords: {_format_coordinates(coords)}") @@ -802,10 +814,9 @@ def _render_summary(summary: dict[str, object]) -> str: director_feed = summary.get("director_feed") if isinstance(director_feed, dict) and director_feed: lines.append(" director feed:") - lines.append( - " focus=" - f"{director_feed.get('focus_center') or 'unset'} suppressed={director_feed.get('suppressed_count', 0)}" - ) + focus_center = director_feed.get("focus_center") or "unset" + suppressed = director_feed.get("suppressed_count", 0) + lines.append(f" focus={focus_center} suppressed={suppressed}") top_ranked = director_feed.get("top_ranked") or [] if top_ranked: preview = [] @@ -818,9 +829,9 @@ def _render_summary(summary: dict[str, object]) -> str: if weights: preview = [] for entry in weights[:2]: - preview.append( - f"{entry.get('district_id', 'n/a')}:{float(entry.get('score', 0.0)):.2f}" - ) + score = float(entry.get("score", 0.0)) + district = entry.get("district_id", "n/a") + preview.append(f"{district}:{score:.2f}") if preview: lines.append(f" spatial: {', '.join(preview)}") analysis = summary.get("director_analysis") @@ -856,16 +867,19 @@ def _render_summary(summary: dict[str, object]) -> str: pacing = summary.get("director_pacing") if isinstance(pacing, dict) and pacing: lines.append(" director pacing:") + active = pacing.get("active", 0) + resolving = pacing.get("resolving", 0) + max_active = pacing.get("max_active", 0) lines.append( - " active/resolving -> " - f"{pacing.get('active', 0)}/{pacing.get('resolving', 0)} (max {pacing.get('max_active', 0)})" + f" active/resolving -> {active}/{resolving} (max {max_active})" ) quiet_until = pacing.get("global_quiet_until") quiet_remaining = pacing.get("global_quiet_remaining") if isinstance(quiet_until, (int, float)) and quiet_until > 0: if isinstance(quiet_remaining, (int, float)) and quiet_remaining > 0: + remaining = int(quiet_remaining) lines.append( - f" quiet until tick {int(quiet_until)} ({int(quiet_remaining)} ticks)" + f" quiet until tick {int(quiet_until)} ({remaining} ticks)" ) else: lines.append(f" quiet until tick {int(quiet_until)}") @@ -946,29 +960,26 @@ def _render_summary(summary: dict[str, object]) -> str: profiling = summary.get("profiling") if isinstance(profiling, dict) and profiling: lines.append(" profiling:") + p50 = profiling.get("tick_ms_p50", 0.0) + p95 = profiling.get("tick_ms_p95", 0.0) + pmax = profiling.get("tick_ms_max", 0.0) lines.append( - " tick ms -> " - f"p50 {profiling.get('tick_ms_p50', 0.0):.2f} | " - f"p95 {profiling.get('tick_ms_p95', 0.0):.2f} | max {profiling.get('tick_ms_max', 0.0):.2f}" + f" tick ms -> p50 {p50:.2f} | p95 {p95:.2f} | max {pmax:.2f}" ) last_subs = profiling.get("last_subsystem_ms") or {} if last_subs: preview = ", ".join( - f"{name}:{value:.2f}ms" - for name, value in list(last_subs.items())[:3] + f"{name}:{value:.2f}ms" for name, value in list(last_subs.items())[:3] ) lines.append(f" last subsystems: {preview}") slowest = profiling.get("slowest_subsystem") if isinstance(slowest, dict) and slowest.get("name"): lines.append( - " slowest: " - f"{slowest['name']} {slowest.get('ms', 0.0):.2f}ms" + f" slowest: {slowest['name']} {slowest.get('ms', 0.0):.2f}ms" ) anomalies = profiling.get("anomalies") or [] if anomalies: - lines.append( - f" anomalies: {', '.join(anomalies[:3])}" - ) + lines.append(f" anomalies: {', '.join(anomalies[:3])}") # Render progression summary if present progression = summary.get("progression") if isinstance(progression, dict) and progression: @@ -977,7 +988,9 @@ def _render_summary(summary: dict[str, object]) -> str: avg_level = progression.get("average_level", 1.0) actions = progression.get("actions_taken", 0) total_exp = progression.get("total_experience", 0.0) - lines.append(f" tier: {tier} | avg level: {avg_level:.1f} | exp: {total_exp:.0f}") + lines.append( + f" tier: {tier} | avg level: {avg_level:.1f} | exp: {total_exp:.0f}" + ) lines.append(f" actions taken: {actions}") skills = progression.get("skills") or {} if skills: @@ -996,6 +1009,7 @@ def _render_summary(summary: dict[str, object]) -> str: lines.append(f" reputation: {', '.join(rep_bits)}") return "\n".join(lines) + def _render_post_mortem(payload: Mapping[str, Any]) -> str: lines = ["Post-mortem recap:"] tick = payload.get("tick") @@ -1008,16 +1022,19 @@ def _render_post_mortem(payload: Mapping[str, Any]) -> str: unrest = float(delta.get("unrest", 0.0)) pollution = float(delta.get("pollution", 0.0)) lines.append( - " environment trend: " - f"stability {stability:+.3f}, unrest {unrest:+.3f}, pollution {pollution:+.3f}" + f" environment trend: stability {stability:+.3f}, " + f"unrest {unrest:+.3f}, pollution {pollution:+.3f}" ) factions = payload.get("faction_trends") or [] if factions: lines.append(" faction swings:") for entry in factions[:3]: + start = entry["start"] + end = entry["end"] + faction_delta = entry["delta"] lines.append( - " - " - f"{entry['faction_id']}: {entry['start']:.3f} → {entry['end']:.3f} ({entry['delta']:+.3f})" + f" - {entry['faction_id']}: {start:.3f} → " + f"{end:.3f} ({faction_delta:+.3f})" ) events = payload.get("featured_events") or [] if events: @@ -1075,12 +1092,15 @@ def _render_focus_state(focus: dict[str, object] | None) -> str: for entry in weights[:4]: distance = entry.get("distance") distance_text = ( - f"{float(distance):.2f}" if isinstance(distance, (int, float)) else "n/a" + f"{float(distance):.2f}" + if isinstance(distance, (int, float)) + else "n/a" ) + score = float(entry.get("score", 0.0)) + pop = float(entry.get("population_rank", 0.0)) lines.append( - " " - f"{entry['district_id']:<16} score {float(entry.get('score', 0.0)):.2f} " - f"pop {float(entry.get('population_rank', 0.0)):.2f} dist {distance_text}" + f" {entry['district_id']:<16} score {score:.2f} " + f"pop {pop:.2f} dist {distance_text}" ) return "\n".join(lines) @@ -1118,10 +1138,13 @@ def _render_director_feed( lines.append(f" ring: {', '.join(ring)}") allocation = feed.get("allocation") or {} if allocation: + focus_used = allocation.get("focus_used", 0) + focus_reserved = allocation.get("focus_reserved", 0) + global_used = allocation.get("global_used", 0) + global_reserved = allocation.get("global_reserved", 0) lines.append( - " allocation -> " - f"focus {allocation.get('focus_used', 0)}/{allocation.get('focus_reserved', 0)} | " - f"global {allocation.get('global_used', 0)}/{allocation.get('global_reserved', 0)}" + f" allocation -> focus {focus_used}/{focus_reserved} | " + f"global {global_used}/{global_reserved}" ) weights = feed.get("spatial_weights") or [] if weights: @@ -1149,9 +1172,11 @@ def _render_director_feed( if history: lines.append(" recent snapshots:") for entry in reversed(history[-3:]): + focus = entry.get("focus_center") or "unset" + suppressed = entry.get("suppressed_count", 0) lines.append( - f" tick {entry.get('tick')}: focus={entry.get('focus_center') or 'unset'} " - f"suppressed={entry.get('suppressed_count', 0)}" + f" tick {entry.get('tick')}: focus={focus} " + f"suppressed={suppressed}" ) if analysis: hotspots = analysis.get("hotspots") or [] @@ -1167,7 +1192,11 @@ def _render_director_feed( if isinstance(hops, int): detail += f"{hops} hops" if isinstance(travel_time, (int, float)): - detail = f"{detail} | {travel_time:.2f}t" if detail else f"{travel_time:.2f}t" + detail = ( + f"{detail} | {travel_time:.2f}t" + if detail + else f"{travel_time:.2f}t" + ) lines.append(f" {prefix}: {detail or 'reachable'}") else: reason = travel.get("reason", "blocked") @@ -1209,9 +1238,16 @@ def _render_director_feed( if isinstance(tick, int): snippet = f"{snippet} [tick {tick}]" lines.append(snippet) - agent = next((agent for agent in event.get("agents", []) if agent.get("name")), None) + agent = next( + (agent for agent in event.get("agents", []) if agent.get("name")), + None, + ) faction = next( - (faction for faction in event.get("factions", []) if faction.get("name")), + ( + faction + for faction in event.get("factions", []) + if faction.get("name") + ), None, ) participants: List[str] = [] @@ -1242,9 +1278,11 @@ def _render_reports(reports: Sequence[TickReport]) -> str: for report in reports: lines.append(f"Tick {report.tick} advanced.") env = report.environment + stb = env["stability"] + unr = env["unrest"] + poll = env["pollution"] lines.append( - " env -> " - f"stb {env['stability']:.2f} | unrest {env['unrest']:.2f} | poll {env['pollution']:.2f}" + f" env -> stb {stb:.2f} | unrest {unr:.2f} | poll {poll:.2f}" ) if report.faction_legitimacy_delta: lines.append(" faction legitimacy:") @@ -1260,7 +1298,8 @@ def _render_reports(reports: Sequence[TickReport]) -> str: prices = report.economy.get("prices", {}) if prices: sample = ", ".join( - f"{resource}:{price:.2f}" for resource, price in sorted(prices.items())[:3] + f"{resource}:{price:.2f}" + for resource, price in sorted(prices.items())[:3] ) lines.append(f" market -> {sample}") if report.events: @@ -1275,9 +1314,8 @@ def _render_reports(reports: Sequence[TickReport]) -> str: f"suppressed {fb.get('suppressed', 0)}" ) if report.suppressed_events: - lines.append( - f" suppressed archive: {len(report.suppressed_events)} events held back" - ) + count = len(report.suppressed_events) + lines.append(f" suppressed archive: {count} events held back") if report.anomalies: lines.append(f" anomalies: {', '.join(report.anomalies)}") return "\n".join(lines) @@ -1304,14 +1342,23 @@ def _render_map(state: GameState, district_id: str | None) -> str: detail.append(f" adjacent : {neighbors}") return "\n".join(detail) - header = "| District ID | District | Pop | Unrest | Poll | Prosper | Sec |" - divider = "+------------------+-----------------+-------+--------+------+---------+-----+" + header = ( + "| District ID | District | Pop | " + "Unrest | Poll | Prosper | Sec |" + ) + divider = ( + "+------------------+-----------------+-------+--------+------+---------+-----+" + ) lines = ["City overview:", divider, header, divider] for district in state.city.districts: + unr = district.modifiers.unrest + poll = district.modifiers.pollution + pros = district.modifiers.prosperity + sec = district.modifiers.security lines.append( - f"| {district.id:<16} | {district.name:<15} | {district.population:5d} | " - f"{district.modifiers.unrest:0.2f} | {district.modifiers.pollution:0.2f} | " - f"{district.modifiers.prosperity:0.2f} | {district.modifiers.security:0.2f} |" + f"| {district.id:<16} | {district.name:<15} | " + f"{district.population:5d} | {unr:0.2f} | {poll:0.2f} | " + f"{pros:0.2f} | {sec:0.2f} |" ) lines.append(divider) lines.append("Geometry overlay:") @@ -1344,8 +1391,8 @@ def _format_coordinates(coords: Any | None) -> str: if coords is None: return "n/a" if hasattr(coords, "x") and hasattr(coords, "y"): - x = getattr(coords, "x") - y = getattr(coords, "y") + x = coords.x + y = coords.y z = getattr(coords, "z", None) elif isinstance(coords, dict): x = coords.get("x") @@ -1424,7 +1471,9 @@ def _render_explanation(entity_type: str, result: Mapping[str, Any]) -> str: home = result.get("home_district") if home: lines.append(f" Home: {home}") - lines.append(f" Reasoning: {result.get('reasoning_summary', 'No recent activity')}") + lines.append( + f" Reasoning: {result.get('reasoning_summary', 'No recent activity')}" + ) needs = result.get("current_needs") or {} if needs: need_strs = [f"{k}:{v:.2f}" for k, v in list(needs.items())[:4]] @@ -1659,7 +1708,7 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument( "--rich", action="store_true", - help="Enable enhanced ASCII views with Rich formatting (tables, colors, panels)", + help="Enable enhanced ASCII views with Rich formatting", ) parser.add_argument( "--campaign", @@ -1696,7 +1745,9 @@ def main(argv: Sequence[str] | None = None) -> int: snapshot_path = campaign_manager.get_snapshot_path(args.campaign) if snapshot_path.exists(): engine.initialize_state(snapshot=snapshot_path) - print(f"Resumed campaign '{campaign.name}' at tick {campaign.last_tick}") + print( + f"Resumed campaign '{campaign.name}' at tick {campaign.last_tick}" + ) except (FileNotFoundError, ValueError) as exc: print(f"Warning: Could not resume campaign: {exc}") diff --git a/src/gengine/echoes/client/service.py b/src/gengine/echoes/client/service.py index a0352a93..bf9227a2 100644 --- a/src/gengine/echoes/client/service.py +++ b/src/gengine/echoes/client/service.py @@ -32,7 +32,9 @@ def tick(self, ticks: int) -> dict[str, Any]: response.raise_for_status() return response.json() - def state(self, detail: str = "summary", district_id: str | None = None) -> dict[str, Any]: + def state( + self, detail: str = "summary", district_id: str | None = None + ) -> dict[str, Any]: params: dict[str, Any] = {"detail": detail} if district_id is not None: params["district_id"] = district_id diff --git a/src/gengine/echoes/content/loader.py b/src/gengine/echoes/content/loader.py index c38df015..a3e57bc0 100644 --- a/src/gengine/echoes/content/loader.py +++ b/src/gengine/echoes/content/loader.py @@ -10,7 +10,14 @@ import yaml from pydantic import ValidationError -from ..core.models import Agent, City, District, DistrictCoordinates, EnvironmentState, Faction, StorySeed +from ..core.models import ( + Agent, + City, + DistrictCoordinates, + EnvironmentState, + Faction, + StorySeed, +) from ..core.state import GameState DEFAULT_WORLD_NAME = "default" @@ -69,13 +76,15 @@ def load_world_bundle( factions = { faction.id: faction for faction in ( - Faction.model_validate(entry) for entry in factions_raw # type: ignore[arg-type] + Faction.model_validate(entry) + for entry in factions_raw # type: ignore[arg-type] ) } agents = { agent.id: agent for agent in ( - Agent.model_validate(entry) for entry in agents_raw # type: ignore[arg-type] + Agent.model_validate(entry) + for entry in agents_raw # type: ignore[arg-type] ) } environment = EnvironmentState.model_validate(env_raw) @@ -121,7 +130,11 @@ def _enrich_district_geometry(city: City, *, max_neighbors: int = 3) -> None: needed = max(0, max_neighbors - len(existing)) if needed <= 0: continue - candidates = [other for other in city.districts if other.id != district.id and other.coordinates] + candidates = [ + other + for other in city.districts + if other.id != district.id and other.coordinates + ] candidates.sort(key=lambda other: _distance(geometry, other.coordinates)) # type: ignore[arg-type] for candidate in candidates: if candidate.id in existing: @@ -131,7 +144,9 @@ def _enrich_district_geometry(city: City, *, max_neighbors: int = 3) -> None: break district.adjacent = existing - adjacency: Dict[str, set[str]] = {district.id: set(district.adjacent) for district in city.districts} + adjacency: Dict[str, set[str]] = { + district.id: set(district.adjacent) for district in city.districts + } for district in city.districts: for neighbor in list(adjacency[district.id]): adjacency.setdefault(neighbor, set()).add(district.id) @@ -198,7 +213,9 @@ def _validate_story_seed_references( errors.append(f"unknown district '{trigger.district_id}' in trigger") if seed.travel_hint and seed.travel_hint.district_id: if seed.travel_hint.district_id not in districts: - errors.append(f"unknown district '{seed.travel_hint.district_id}' in travel_hint") + errors.append( + f"unknown district '{seed.travel_hint.district_id}' in travel_hint" + ) for agent_id in seed.roles.agents: if agent_id not in agent_ids: errors.append(f"unknown agent '{agent_id}' in roles") diff --git a/src/gengine/echoes/core/__init__.py b/src/gengine/echoes/core/__init__.py index 86dec404..420bc349 100644 --- a/src/gengine/echoes/core/__init__.py +++ b/src/gengine/echoes/core/__init__.py @@ -2,15 +2,15 @@ from .models import Agent, City, District, EnvironmentState, Faction, ResourceStock from .progression import ( + EXPERTISE_MAX_PIPS, + SPECIALIZATION_DOMAIN_MAP, AccessTier, AgentProgressionState, AgentSpecialization, - EXPERTISE_MAX_PIPS, ProgressionState, ReputationState, SkillDomain, SkillState, - SPECIALIZATION_DOMAIN_MAP, calculate_agent_modifier, calculate_success_modifier, ) diff --git a/src/gengine/echoes/core/models.py b/src/gengine/echoes/core/models.py index 3c2b55ff..96ea8646 100644 --- a/src/gengine/echoes/core/models.py +++ b/src/gengine/echoes/core/models.py @@ -36,6 +36,7 @@ class DistrictModifiers(BaseModel): model_config = {"validate_assignment": True} + class DistrictCoordinates(BaseModel): """Planar or 3D coordinates for a district.""" @@ -138,7 +139,9 @@ class StorySeed(BaseModel): @field_validator("triggers") @classmethod - def _validate_triggers(cls, value: List[StorySeedTrigger]) -> List[StorySeedTrigger]: # type: ignore[override] + def _validate_triggers( + cls, value: List[StorySeedTrigger] + ) -> List[StorySeedTrigger]: # type: ignore[override] if not value: raise ValueError("story seed must define at least one trigger") return value diff --git a/src/gengine/echoes/core/state.py b/src/gengine/echoes/core/state.py index 99c34ff2..89f1d239 100644 --- a/src/gengine/echoes/core/state.py +++ b/src/gengine/echoes/core/state.py @@ -64,9 +64,7 @@ def ensure_agent_progression( ) return self.agent_progression[agent_id] - def get_agent_progression( - self, agent_id: str - ) -> Optional[AgentProgressionState]: + def get_agent_progression(self, agent_id: str) -> Optional[AgentProgressionState]: """Get per-agent progression state if it exists, otherwise None.""" return self.agent_progression.get(agent_id) diff --git a/src/gengine/echoes/gateway/app.py b/src/gengine/echoes/gateway/app.py index 209188f7..2898f761 100644 --- a/src/gengine/echoes/gateway/app.py +++ b/src/gengine/echoes/gateway/app.py @@ -12,7 +12,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from ..cli.shell import PROMPT, ShellBackend, ServiceBackend +from ..cli.shell import PROMPT, ServiceBackend, ShellBackend from ..client import SimServiceClient from ..settings import SimulationConfig, load_simulation_config from .llm_client import LLMClient @@ -34,7 +34,9 @@ class GatewaySettings: @classmethod def from_env(cls) -> "GatewaySettings": return cls( - service_url=os.environ.get("ECHOES_GATEWAY_SERVICE_URL", "http://localhost:8000"), + service_url=os.environ.get( + "ECHOES_GATEWAY_SERVICE_URL", "http://localhost:8000" + ), llm_service_url=os.environ.get("ECHOES_GATEWAY_LLM_URL"), host=os.environ.get("ECHOES_GATEWAY_HOST", "0.0.0.0"), port=int(os.environ.get("ECHOES_GATEWAY_PORT", "8100")), @@ -107,11 +109,11 @@ async def websocket_handler(websocket: WebSocket) -> None: } ) continue - + # Check if this is a natural language command command = message_data.get("command") is_nl = message_data.get("natural_language", False) - + if command is None: await websocket.send_json( { @@ -120,10 +122,12 @@ async def websocket_handler(websocket: WebSocket) -> None: } ) continue - + try: if is_nl and session.llm_client: - result = await asyncio.to_thread(session.execute_natural_language, command) + result = await asyncio.to_thread( + session.execute_natural_language, command + ) else: result = await asyncio.to_thread(session.execute, command) except Exception as exc: # pragma: no cover - unexpected failure @@ -183,12 +187,14 @@ def open_session(self) -> GatewaySession: # Check LLM service health if not llm_client.healthcheck(): LOGGER.warning("LLM service unhealthy at %s", self._llm_service_url) - return GatewaySession(backend, limits=self._config.limits, llm_client=llm_client) + return GatewaySession( + backend, limits=self._config.limits, llm_client=llm_client + ) async def _receive_message(websocket: WebSocket) -> dict[str, str | bool] | None: """Receive and parse message from WebSocket. - + Returns dict with 'command' and optional 'natural_language' flag, or None if message is invalid. """ @@ -196,10 +202,10 @@ async def _receive_message(websocket: WebSocket) -> dict[str, str | bool] | None message = await websocket.receive() except RuntimeError as exc: # starlette raises RuntimeError after disconnect raise WebSocketDisconnect(code=1000) from exc - + text = message.get("text") data = None - + if text is not None: text = text.strip() if not text: @@ -217,12 +223,15 @@ async def _receive_message(websocket: WebSocket) -> dict[str, str | bool] | None data = json.loads(payload.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): # Binary decoded as text command - return {"command": payload.decode("utf-8", errors="ignore"), "natural_language": False} - + return { + "command": payload.decode("utf-8", errors="ignore"), + "natural_language": False, + } + if isinstance(data, dict): command = data.get("command") if isinstance(command, str): is_nl = data.get("natural_language", False) return {"command": command, "natural_language": bool(is_nl)} - + return None diff --git a/src/gengine/echoes/gateway/client.py b/src/gengine/echoes/gateway/client.py index 3f89206b..681f6a82 100644 --- a/src/gengine/echoes/gateway/client.py +++ b/src/gengine/echoes/gateway/client.py @@ -68,7 +68,9 @@ def main(argv: Sequence[str] | None = None) -> int: asyncio.run(_run_session(args.gateway_url, script)) except KeyboardInterrupt: # pragma: no cover - interactive cancel return 0 - except websockets.exceptions.ConnectionClosedError as exc: # pragma: no cover - passthrough + except ( + websockets.exceptions.ConnectionClosedError + ) as exc: # pragma: no cover - passthrough print(f"Gateway connection closed: {exc}") return 1 except OSError as exc: # pragma: no cover - networking failure diff --git a/src/gengine/echoes/gateway/intent_mapper.py b/src/gengine/echoes/gateway/intent_mapper.py index 53e83184..d10af9e9 100644 --- a/src/gengine/echoes/gateway/intent_mapper.py +++ b/src/gengine/echoes/gateway/intent_mapper.py @@ -3,14 +3,12 @@ from __future__ import annotations import logging -from typing import Any from ..llm import ( CovertActionIntent, DeployResourceIntent, GameIntent, InspectIntent, - IntentType, InvokeAgentIntent, NegotiateIntent, PassPolicyIntent, @@ -22,20 +20,20 @@ class IntentMapper: """Converts GameIntent objects to simulation commands. - + This mapper translates structured intents from the LLM into commands that can be executed through the shell backend. """ def map_intent_to_command(self, intent: GameIntent) -> str: """Convert a GameIntent to a shell command string. - + Args: intent: The parsed game intent - + Returns: Shell command string that implements the intent - + Raises: ValueError: If intent type is not supported """ @@ -60,14 +58,18 @@ def _map_inspect(self, intent: InspectIntent) -> str: """Map INSPECT intent to shell command.""" target_type = intent.target_type target_id = intent.target_id - + if target_type == "district": # Map to 'map ' command return f"map {target_id}" elif target_type in ("agent", "faction"): # For now, use summary to show agent/faction info # Future: add dedicated agent/faction detail commands - LOGGER.info("INSPECT %s '%s' - using summary (no dedicated command yet)", target_type, target_id) + LOGGER.info( + "INSPECT %s '%s' - using summary (no dedicated command yet)", + target_type, + target_id, + ) return "summary" else: LOGGER.warning("Unknown INSPECT target_type: %s", target_type) @@ -90,7 +92,8 @@ def _map_deploy_resource(self, intent: DeployResourceIntent) -> str: # Resource deployment requires action API not yet exposed via CLI # For now, log the intent and return a summary LOGGER.info( - "DEPLOY_RESOURCE intent: type=%s amount=%d district=%s (not yet implemented)", + "DEPLOY_RESOURCE intent: type=%s amount=%d district=%s " + "(not yet implemented)", intent.resource_type, intent.amount, intent.target_district, @@ -135,7 +138,7 @@ def _map_invoke_agent(self, intent: InvokeAgentIntent) -> str: def _map_request_report(self, intent: RequestReportIntent) -> str: """Map REQUEST_REPORT intent to shell command.""" report_type = intent.report_type - + if report_type == "summary": return "summary" elif report_type == "district": diff --git a/src/gengine/echoes/gateway/llm_client.py b/src/gengine/echoes/gateway/llm_client.py index 2373c486..45728b95 100644 --- a/src/gengine/echoes/gateway/llm_client.py +++ b/src/gengine/echoes/gateway/llm_client.py @@ -14,7 +14,7 @@ class LLMClient: """HTTP client for the LLM service. - + Provides intent parsing and narration with retry logic and fallback handling. """ @@ -26,7 +26,7 @@ def __init__( max_retries: int = 2, ) -> None: """Initialize LLM client. - + Args: base_url: Base URL of the LLM service (e.g., "http://localhost:8001") timeout: Request timeout in seconds @@ -54,11 +54,11 @@ def parse_intent( context: dict[str, Any] | None = None, ) -> GameIntent | None: """Parse natural language text into a structured game intent. - + Args: text: Natural language command from user context: Optional context (district, tick, recent_events, etc.) - + Returns: Parsed GameIntent or None if parsing fails """ @@ -74,17 +74,19 @@ def parse_intent( ) response.raise_for_status() data = response.json() - + # The LLM response should contain intent data directly # or wrapped in an "intent" field intent_data = data.get("intent", data) - + if not isinstance(intent_data, dict): - LOGGER.warning("LLM response intent is not a dict: %s", type(intent_data)) + LOGGER.warning( + "LLM response intent is not a dict: %s", type(intent_data) + ) return None - + return parse_intent(intent_data) - + except httpx.HTTPStatusError as exc: LOGGER.warning( "LLM parse_intent failed (attempt %d/%d): HTTP %d", @@ -95,7 +97,7 @@ def parse_intent( if attempt == self.max_retries: LOGGER.error("LLM parse_intent exhausted retries") return None - + except (httpx.RequestError, ValueError) as exc: LOGGER.warning( "LLM parse_intent error (attempt %d/%d): %s", @@ -116,11 +118,11 @@ def narrate( context: dict[str, Any] | None = None, ) -> str | None: """Generate narrative text from simulation events. - + Args: events: List of event strings from simulation context: Optional context (district, tick, etc.) - + Returns: Generated narration or None if generation fails """ @@ -136,14 +138,14 @@ def narrate( ) response.raise_for_status() data = response.json() - + narration = data.get("narration") if not narration: LOGGER.warning("LLM response missing 'narration' field: %s", data) return None - + return str(narration) - + except httpx.HTTPStatusError as exc: LOGGER.warning( "LLM narrate failed (attempt %d/%d): HTTP %d", @@ -154,7 +156,7 @@ def narrate( if attempt == self.max_retries: LOGGER.error("LLM narrate exhausted retries") return None - + except (httpx.RequestError, ValueError) as exc: LOGGER.warning( "LLM narrate error (attempt %d/%d): %s", @@ -170,7 +172,7 @@ def narrate( def healthcheck(self) -> bool: """Check if LLM service is healthy. - + Returns: True if service is healthy, False otherwise """ diff --git a/src/gengine/echoes/gateway/session.py b/src/gengine/echoes/gateway/session.py index 3b1932b0..1fdcb49f 100644 --- a/src/gengine/echoes/gateway/session.py +++ b/src/gengine/echoes/gateway/session.py @@ -51,10 +51,10 @@ def execute(self, command: str) -> CommandResult: def execute_natural_language(self, text: str) -> CommandResult: """Execute a natural language command using LLM intent parsing. - + Args: text: Natural language text from user - + Returns: CommandResult with output or error """ @@ -67,11 +67,11 @@ def execute_natural_language(self, text: str) -> CommandResult: # Build context for intent parsing summary = self.backend.summary() context = self._build_intent_context(summary) - + # Parse intent with LLM LOGGER.info("Parsing NL command: %s", text[:100]) intent = self.llm_client.parse_intent(text, context=context) - + if not intent: LOGGER.warning("Failed to parse intent from: %s", text) fallback = self._fallback_command(text) @@ -80,7 +80,8 @@ def execute_natural_language(self, text: str) -> CommandResult: result = self.shell.execute(fallback) else: result = CommandResult( - output="I couldn't understand that command. Try: summary, map, next, run ", + output="I couldn't understand that command. " + "Try: summary, map, next, run ", should_exit=False, ) self.conversation_history.append({"user": text, "result": "fallback"}) @@ -89,7 +90,9 @@ def execute_natural_language(self, text: str) -> CommandResult: # Map intent to command try: command = self.intent_mapper.map_intent_to_command(intent) - LOGGER.info("Mapped intent %s -> command: %s", type(intent).__name__, command) + LOGGER.info( + "Mapped intent %s -> command: %s", type(intent).__name__, command + ) except ValueError as exc: LOGGER.error("Failed to map intent: %s", exc) result = CommandResult( @@ -101,7 +104,7 @@ def execute_natural_language(self, text: str) -> CommandResult: # Execute the mapped command result = self.shell.execute(command) - + # Try to narrate the result if we have events if result.output and self.llm_client: narration = self._try_narrate(result.output, context) @@ -110,13 +113,15 @@ def execute_natural_language(self, text: str) -> CommandResult: output=f"{narration}\n\n{result.output}", should_exit=result.should_exit, ) - - self.conversation_history.append({ - "user": text, - "intent": type(intent).__name__, - "command": command, - }) - + + self.conversation_history.append( + { + "user": text, + "intent": type(intent).__name__, + "command": command, + } + ) + return result def close(self) -> None: @@ -128,27 +133,29 @@ def close(self) -> None: def _build_intent_context(self, summary: dict[str, Any]) -> dict[str, Any]: """Build context dictionary for LLM intent parsing.""" context: dict[str, Any] = {} - + if "tick" in summary: context["tick"] = summary["tick"] - + if "focus" in summary and isinstance(summary["focus"], dict): - focus_center = summary["focus"].get("district_id") or summary["focus"].get("focus_center") + focus_center = summary["focus"].get("district_id") or summary["focus"].get( + "focus_center" + ) if focus_center: context["district"] = focus_center - + # Add recent events from digest if available if "focus_digest" in summary and isinstance(summary["focus_digest"], dict): events = summary["focus_digest"].get("events", []) if events: context["recent_events"] = events[:3] # Limit to 3 most recent - + return context def _fallback_command(self, text: str) -> str | None: """Attempt keyword-based fallback when LLM parsing fails.""" text_lower = text.lower() - + # Simple keyword matching for common commands if "summary" in text_lower or "status" in text_lower: return "summary" @@ -160,22 +167,22 @@ def _fallback_command(self, text: str) -> str | None: return "history" elif "director" in text_lower: return "director" - + return None def _try_narrate(self, output: str, context: dict[str, Any]) -> str | None: """Try to generate narrative from command output.""" if not self.llm_client: return None - + # Extract events from output for narration # For now, just pass the first few lines as events lines = output.strip().split("\n") events = [line.strip() for line in lines[:5] if line.strip()] - + if not events: return None - + narration = self.llm_client.narrate(events, context=context) return narration @@ -189,7 +196,9 @@ def _log_focus( focus = summary.get("focus") if isinstance(summary, dict) else None digest = summary.get("focus_digest") if isinstance(summary, dict) else None history = summary.get("focus_history") if isinstance(summary, dict) else None - director_history = summary.get("director_history") if isinstance(summary, dict) else None + director_history = ( + summary.get("director_history") if isinstance(summary, dict) else None + ) focus_center = None if isinstance(focus, dict): focus_center = focus.get("district_id") or focus.get("focus_center") @@ -200,10 +209,13 @@ def _log_focus( history_len = len(history_snapshot) else: history_len = len(history or []) if isinstance(history, Iterable) else 0 - director_history_len = len(director_history or []) if isinstance(director_history, Iterable) else 0 + director_history_len = ( + len(director_history or []) if isinstance(director_history, Iterable) else 0 + ) tick = summary.get("tick") if isinstance(summary, dict) else None LOGGER.info( - "gateway session=%s label=%s tick=%s focus=%s suppressed=%s history=%s director_history=%s", + "gateway session=%s label=%s tick=%s focus=%s " + "suppressed=%s history=%s director_history=%s", self.session_id, label, tick, diff --git a/src/gengine/echoes/llm/anthropic_provider.py b/src/gengine/echoes/llm/anthropic_provider.py index 96ec0e53..6a507cdd 100644 --- a/src/gengine/echoes/llm/anthropic_provider.py +++ b/src/gengine/echoes/llm/anthropic_provider.py @@ -8,7 +8,6 @@ from anthropic import Anthropic, AnthropicError -from . import parse_intent from .prompts import ( ANTHROPIC_INTENT_SCHEMA, INTENT_PARSING_SYSTEM_PROMPT, @@ -76,20 +75,18 @@ async def parse_intent( max_tokens=1000, temperature=0.3, system=INTENT_PARSING_SYSTEM_PROMPT, - messages=[ - {"role": "user", "content": full_prompt} - ], + messages=[{"role": "user", "content": full_prompt}], ) raw_response = response.model_dump_json() # Extract JSON from response content = response.content[0].text if response.content else "" - + # Try to parse JSON from content intents = [] confidence = 0.0 - + try: # Look for JSON in the response json_start = content.find("{") @@ -97,7 +94,7 @@ async def parse_intent( if json_start >= 0 and json_end > json_start: json_str = content[json_start:json_end] parsed = json.loads(json_str) - + # Convert to intent dict with session_id intent_dict = self._structured_output_to_intent(parsed, context) if intent_dict: @@ -107,7 +104,8 @@ async def parse_intent( logger.warning(f"Failed to parse JSON from Anthropic response: {e}") logger.info( - f"Anthropic parsed intent: {len(intents)} intent(s) from '{user_input[:50]}...'" + f"Anthropic parsed intent: {len(intents)} intent(s) " + f"from '{user_input[:50]}...'" ) return IntentParseResult( @@ -145,9 +143,7 @@ async def narrate( """ try: # Convert event dicts to strings - event_strings = [ - e.get("description", str(e)) for e in events - ] + event_strings = [e.get("description", str(e)) for e in events] prompt = build_narration_prompt(event_strings, context=context) response = self.client.messages.create( @@ -155,9 +151,7 @@ async def narrate( max_tokens=500, temperature=0.7, system=NARRATION_SYSTEM_PROMPT, - messages=[ - {"role": "user", "content": prompt} - ], + messages=[{"role": "user", "content": prompt}], ) raw_response = response.model_dump_json() @@ -166,7 +160,10 @@ async def narrate( metadata = { "model": self.model, "event_count": len(events), - "tokens_used": response.usage.input_tokens + response.usage.output_tokens if response.usage else 0, + "tokens_used": response.usage.input_tokens + + response.usage.output_tokens + if response.usage + else 0, } logger.info(f"Anthropic narrated {len(events)} events") diff --git a/src/gengine/echoes/llm/app.py b/src/gengine/echoes/llm/app.py index b7d6379b..4e06489c 100644 --- a/src/gengine/echoes/llm/app.py +++ b/src/gengine/echoes/llm/app.py @@ -53,14 +53,14 @@ def create_llm_app( settings: LLMSettings | None = None, ) -> FastAPI: """Create FastAPI application for LLM service. - + Parameters ---------- provider Pre-configured LLM provider. If None, creates from settings. settings LLM settings. If None and provider is None, loads from environment. - + Returns ------- FastAPI @@ -93,7 +93,7 @@ async def health_check() -> dict[str, Any]: @app.post("/parse_intent", response_model=ParseIntentResponse) async def parse_intent(request: ParseIntentRequest) -> ParseIntentResponse: """Parse natural language input into structured intents. - + Takes user input and game context, returns structured intent objects that can be routed to the simulation service. """ @@ -111,12 +111,12 @@ async def parse_intent(request: ParseIntentRequest) -> ParseIntentResponse: raise HTTPException( status_code=500, detail=f"Intent parsing failed: {str(e)}", - ) + ) from e @app.post("/narrate", response_model=NarrateResponse) async def narrate(request: NarrateRequest) -> NarrateResponse: """Generate narrative description of game events. - + Takes game events and context, returns natural language narrative suitable for presenting to the player. """ @@ -134,6 +134,6 @@ async def narrate(request: NarrateRequest) -> NarrateResponse: raise HTTPException( status_code=500, detail=f"Narration failed: {str(e)}", - ) + ) from e return app diff --git a/src/gengine/echoes/llm/intents.py b/src/gengine/echoes/llm/intents.py index 306baf48..10564ce9 100644 --- a/src/gengine/echoes/llm/intents.py +++ b/src/gengine/echoes/llm/intents.py @@ -41,7 +41,8 @@ class InspectIntent(GameIntent): ) target_id: str = Field(..., description="ID of the target to inspect") focus_areas: Optional[list[str]] = Field( - None, description="Specific aspects to focus on (e.g., ['pollution', 'stability'])" + None, + description="Specific aspects to focus on (e.g., ['pollution', 'stability'])", ) @field_validator("target_type") @@ -64,7 +65,8 @@ class NegotiateIntent(GameIntent): description="Negotiation levers (e.g., resource offers, policy promises)", ) goal: Optional[str] = Field( - None, description="Desired outcome (e.g., 'increase legitimacy', 'reduce unrest')" + None, + description="Desired outcome (e.g., 'increase legitimacy', 'reduce unrest')", ) @field_validator("targets") @@ -84,7 +86,8 @@ class DeployResourceIntent(GameIntent): amount: float = Field(..., description="Amount to deploy", ge=0) target_district: str = Field(..., description="District ID to deploy to") purpose: Optional[str] = Field( - None, description="Purpose of deployment (e.g., 'stabilize', 'boost production')" + None, + description="Purpose of deployment (e.g., 'stabilize', 'boost production')", ) @field_validator("resource_type") @@ -120,9 +123,7 @@ class CovertActionIntent(GameIntent): parameters: dict[str, Any] = Field( default_factory=dict, description="Action-specific parameters" ) - risk_level: Optional[str] = Field( - None, description="Risk level: low, medium, high" - ) + risk_level: Optional[str] = Field(None, description="Risk level: low, medium, high") @field_validator("risk_level") @classmethod @@ -155,9 +156,7 @@ class RequestReportIntent(GameIntent): filters: dict[str, Any] = Field( default_factory=dict, description="Filters for the report" ) - include_history: bool = Field( - default=False, description="Include historical data" - ) + include_history: bool = Field(default=False, description="Include historical data") @field_validator("report_type") @classmethod diff --git a/src/gengine/echoes/llm/main.py b/src/gengine/echoes/llm/main.py index 2bfd85be..a5513843 100644 --- a/src/gengine/echoes/llm/main.py +++ b/src/gengine/echoes/llm/main.py @@ -15,7 +15,7 @@ def main() -> None: """Run the LLM service.""" settings = LLMSettings.from_env() - + try: settings.validate() except ValueError as e: @@ -23,14 +23,14 @@ def main() -> None: raise app = create_llm_app(settings=settings) - + host = "0.0.0.0" port = 8001 # Different port from simulation service (8000) - + logger.info(f"Starting LLM service with provider '{settings.provider}'") if settings.model: logger.info(f"Using model: {settings.model}") - + uvicorn.run(app, host=host, port=port) diff --git a/src/gengine/echoes/llm/openai_provider.py b/src/gengine/echoes/llm/openai_provider.py index a78d0ea7..58f3fe32 100644 --- a/src/gengine/echoes/llm/openai_provider.py +++ b/src/gengine/echoes/llm/openai_provider.py @@ -8,7 +8,6 @@ from openai import AsyncOpenAI, OpenAIError -from . import parse_intent from .prompts import ( INTENT_PARSING_SYSTEM_PROMPT, NARRATION_SYSTEM_PROMPT, @@ -94,7 +93,8 @@ async def parse_intent( confidence = 0.9 if intents else 0.3 logger.info( - f"OpenAI parsed intent: {len(intents)} intent(s) from '{user_input[:50]}...'" + f"OpenAI parsed intent: {len(intents)} intent(s) " + f"from '{user_input[:50]}...'" ) return IntentParseResult( @@ -132,9 +132,7 @@ async def narrate( """ try: # Convert event dicts to strings - event_strings = [ - e.get("description", str(e)) for e in events - ] + event_strings = [e.get("description", str(e)) for e in events] prompt = build_narration_prompt(event_strings, context=context) response = await self.client.chat.completions.create( @@ -218,50 +216,64 @@ def _function_call_to_intent( # Map function args to intent fields based on type if intent_type == "INSPECT": - intent_dict.update({ - "target_type": args.get("target_type"), - "target_id": args.get("target_id"), - "focus_areas": args.get("focus_areas", []), - }) + intent_dict.update( + { + "target_type": args.get("target_type"), + "target_id": args.get("target_id"), + "focus_areas": args.get("focus_areas", []), + } + ) elif intent_type == "NEGOTIATE": - intent_dict.update({ - "targets": args.get("targets", []), - "levers": args.get("levers", {}), - "goal": args.get("goal", ""), - }) + intent_dict.update( + { + "targets": args.get("targets", []), + "levers": args.get("levers", {}), + "goal": args.get("goal", ""), + } + ) elif intent_type == "DEPLOY_RESOURCE": - intent_dict.update({ - "resource_type": args.get("resource_type"), - "amount": args.get("amount"), - "target_district": args.get("target_district"), - "purpose": args.get("purpose"), - }) + intent_dict.update( + { + "resource_type": args.get("resource_type"), + "amount": args.get("amount"), + "target_district": args.get("target_district"), + "purpose": args.get("purpose"), + } + ) elif intent_type == "PASS_POLICY": - intent_dict.update({ - "policy_id": args.get("policy_id"), - "parameters": args.get("parameters", {}), - "duration_ticks": args.get("duration_ticks"), - }) + intent_dict.update( + { + "policy_id": args.get("policy_id"), + "parameters": args.get("parameters", {}), + "duration_ticks": args.get("duration_ticks"), + } + ) elif intent_type == "COVERT_ACTION": - intent_dict.update({ - "action_type": args.get("action_type"), - "target_district": args.get("target_district"), - "target_faction": args.get("target_faction"), - "parameters": args.get("parameters", {}), - "risk_level": args.get("risk_level"), - }) + intent_dict.update( + { + "action_type": args.get("action_type"), + "target_district": args.get("target_district"), + "target_faction": args.get("target_faction"), + "parameters": args.get("parameters", {}), + "risk_level": args.get("risk_level"), + } + ) elif intent_type == "INVOKE_AGENT": - intent_dict.update({ - "agent_id": args.get("agent_id"), - "action": args.get("action"), - "target": args.get("target"), - "parameters": args.get("parameters", {}), - }) + intent_dict.update( + { + "agent_id": args.get("agent_id"), + "action": args.get("action"), + "target": args.get("target"), + "parameters": args.get("parameters", {}), + } + ) elif intent_type == "REQUEST_REPORT": - intent_dict.update({ - "report_type": args.get("report_type"), - "filters": args.get("filters", {}), - "include_history": args.get("include_history", False), - }) + intent_dict.update( + { + "report_type": args.get("report_type"), + "filters": args.get("filters", {}), + "include_history": args.get("include_history", False), + } + ) return intent_dict diff --git a/src/gengine/echoes/llm/prompts.py b/src/gengine/echoes/llm/prompts.py index 560318b7..cd83406f 100644 --- a/src/gengine/echoes/llm/prompts.py +++ b/src/gengine/echoes/llm/prompts.py @@ -49,7 +49,8 @@ }, "levers": { "type": "object", - "description": "Negotiation levers (resource offers, policy promises)", + "description": "Negotiation levers " + "(resource offers, policy promises)", }, "goal": { "type": "string", @@ -193,7 +194,14 @@ "properties": { "report_type": { "type": "string", - "enum": ["summary", "district", "faction", "agent", "environment", "director"], + "enum": [ + "summary", + "district", + "faction", + "agent", + "environment", + "director", + ], "description": "Type of report to generate", }, "filters": { @@ -215,24 +223,28 @@ ] # System prompt for intent parsing -INTENT_PARSING_SYSTEM_PROMPT = """You are an AI assistant helping players interact with "Echoes of Emergence", a city simulation game. - -Your role is to parse player commands into structured game intents. Players can: -- INSPECT districts, agents, or factions to gather information -- NEGOTIATE with factions to broker deals or resolve conflicts -- DEPLOY resources (materials/energy) to districts -- PASS policies that affect the entire city -- Execute COVERT actions for hidden operations -- INVOKE specific agents to take actions -- REQUEST reports about simulation state - -Always use the provided functions to structure your responses. Extract key details from player text: -- Target entities (districts, agents, factions) -- Resource types and amounts -- Goals and purposes -- Any relevant context - -Be permissive in parsing - if the player's intent is unclear but seems like one of these actions, make a reasonable guess and include context about the uncertainty.""" +INTENT_PARSING_SYSTEM_PROMPT = ( + "You are an AI assistant helping players interact with " + '"Echoes of Emergence", a city simulation game.\n\n' + "Your role is to parse player commands into structured game intents. " + "Players can:\n" + "- INSPECT districts, agents, or factions to gather information\n" + "- NEGOTIATE with factions to broker deals or resolve conflicts\n" + "- DEPLOY resources (materials/energy) to districts\n" + "- PASS policies that affect the entire city\n" + "- Execute COVERT actions for hidden operations\n" + "- INVOKE specific agents to take actions\n" + "- REQUEST reports about simulation state\n\n" + "Always use the provided functions to structure your responses. " + "Extract key details from player text:\n" + "- Target entities (districts, agents, factions)\n" + "- Resource types and amounts\n" + "- Goals and purposes\n" + "- Any relevant context\n\n" + "Be permissive in parsing - if the player's intent is unclear but " + "seems like one of these actions, make a reasonable guess and include " + "context about the uncertainty." +) # Anthropic structured output schema ANTHROPIC_INTENT_SCHEMA = { @@ -268,26 +280,32 @@ } # Narration system prompt -NARRATION_SYSTEM_PROMPT = """You are a narrative generator for "Echoes of Emergence", a city simulation game. - -Your role is to transform simulation events into engaging story text. Given a list of events and context: -- Weave events into a cohesive narrative -- Match the tone to the context (neutral, tense, hopeful, etc.) -- Use vivid but concise language -- Connect events causally when possible -- Maintain consistency with the game world - -The game world: -- Near-future city dealing with resource scarcity and political tensions -- Three main districts: Industrial Tier (production), Perimeter Hollow (volatile), Spire (elite) -- Two factions: Union of Flux (labor/environment) and Compact Majority (order/tradition) -- Agents act as key NPCs driving events - -Keep narrations brief (2-4 sentences) unless context suggests more detail is needed.""" +NARRATION_SYSTEM_PROMPT = ( + "You are a narrative generator for " + '"Echoes of Emergence", a city simulation game.\n\n' + "Your role is to transform simulation events into engaging story text. " + "Given a list of events and context:\n" + "- Weave events into a cohesive narrative\n" + "- Match the tone to the context (neutral, tense, hopeful, etc.)\n" + "- Use vivid but concise language\n" + "- Connect events causally when possible\n" + "- Maintain consistency with the game world\n\n" + "The game world:\n" + "- Near-future city dealing with resource scarcity and political tensions\n" + "- Three main districts: Industrial Tier (production), " + "Perimeter Hollow (volatile), Spire (elite)\n" + "- Two factions: Union of Flux (labor/environment) and " + "Compact Majority (order/tradition)\n" + "- Agents act as key NPCs driving events\n\n" + "Keep narrations brief (2-4 sentences) unless context suggests more " + "detail is needed." +) def build_intent_parsing_prompt( - user_text: str, available_actions: list[str] | None = None, context: dict[str, Any] | None = None + user_text: str, + available_actions: list[str] | None = None, + context: dict[str, Any] | None = None, ) -> str: """Build a complete prompt for intent parsing. @@ -315,7 +333,8 @@ def build_intent_parsing_prompt( prompt_parts.append(f"\nRecent events: {events_str}") prompt_parts.append( - "\nParse this command into the most appropriate game intent using the available functions." + "\nParse this command into the most appropriate game intent " + "using the available functions." ) return "\n".join(prompt_parts) @@ -334,7 +353,10 @@ def build_narration_prompt( Formatted prompt string """ if not events: - return "Generate a brief observation that nothing significant has occurred recently." + return ( + "Generate a brief observation that nothing significant " + "has occurred recently." + ) prompt_parts = ["Generate a narrative for these simulation events:"] diff --git a/src/gengine/echoes/llm/providers.py b/src/gengine/echoes/llm/providers.py index 2114fba9..0151e996 100644 --- a/src/gengine/echoes/llm/providers.py +++ b/src/gengine/echoes/llm/providers.py @@ -12,7 +12,7 @@ @dataclass class IntentParseResult: """Result from parsing user input into structured intents. - + Attributes ---------- intents @@ -31,7 +31,7 @@ class IntentParseResult: @dataclass class NarrateResult: """Result from generating narrative response. - + Attributes ---------- narrative @@ -52,7 +52,7 @@ class LLMProvider(ABC): def __init__(self, settings: LLMSettings) -> None: """Initialize provider with settings. - + Parameters ---------- settings @@ -67,14 +67,14 @@ async def parse_intent( context: dict[str, Any], ) -> IntentParseResult: """Parse user input into structured intents. - + Parameters ---------- user_input Natural language input from user context Game state context for intent parsing - + Returns ------- IntentParseResult @@ -89,14 +89,14 @@ async def narrate( context: dict[str, Any], ) -> NarrateResult: """Generate narrative description of game events. - + Parameters ---------- events List of game events to narrate context Game state context for narrative generation - + Returns ------- NarrateResult @@ -107,7 +107,7 @@ async def narrate( class StubProvider(LLMProvider): """Stub LLM provider for offline testing. - + Returns deterministic responses without making API calls. Useful for testing and development without incurring API costs. """ @@ -123,25 +123,33 @@ async def parse_intent( intents = [] if "inspect" in user_lower or "check" in user_lower or "status" in user_lower: - intents.append({ - "type": "inspect", - "target": "district" if "district" in user_lower else "city", - }) + intents.append( + { + "type": "inspect", + "target": "district" if "district" in user_lower else "city", + } + ) elif "stabilize" in user_lower or "calm" in user_lower: - intents.append({ - "type": "stabilize", - "target": "district", - }) + intents.append( + { + "type": "stabilize", + "target": "district", + } + ) elif "negotiate" in user_lower or "talk" in user_lower: - intents.append({ - "type": "negotiate", - "target": "faction", - }) + intents.append( + { + "type": "negotiate", + "target": "faction", + } + ) else: - intents.append({ - "type": "observe", - "target": "city", - }) + intents.append( + { + "type": "observe", + "target": "city", + } + ) return IntentParseResult( intents=intents, @@ -175,17 +183,17 @@ async def narrate( def create_provider(settings: LLMSettings) -> LLMProvider: """Factory function to create LLM provider based on settings. - + Parameters ---------- settings LLM configuration settings - + Returns ------- LLMProvider Configured provider instance - + Raises ------ ValueError @@ -197,9 +205,11 @@ def create_provider(settings: LLMSettings) -> LLMProvider: return StubProvider(settings) elif settings.provider == "openai": from .openai_provider import OpenAIProvider + return OpenAIProvider(settings) elif settings.provider == "anthropic": from .anthropic_provider import AnthropicProvider + return AnthropicProvider(settings) else: raise ValueError(f"Unsupported provider: {settings.provider}") diff --git a/src/gengine/echoes/llm/settings.py b/src/gengine/echoes/llm/settings.py index ea413103..90cc4671 100644 --- a/src/gengine/echoes/llm/settings.py +++ b/src/gengine/echoes/llm/settings.py @@ -9,7 +9,7 @@ @dataclass class LLMSettings: """Configuration for LLM service and providers. - + Attributes ---------- provider @@ -37,7 +37,7 @@ class LLMSettings: @classmethod def from_env(cls) -> LLMSettings: """Load settings from environment variables. - + Environment Variables --------------------- ECHOES_LLM_PROVIDER : str @@ -56,7 +56,7 @@ def from_env(cls) -> LLMSettings: provider = os.getenv("ECHOES_LLM_PROVIDER", "stub") api_key = os.getenv("ECHOES_LLM_API_KEY") model = os.getenv("ECHOES_LLM_MODEL") - + temperature = float(os.getenv("ECHOES_LLM_TEMPERATURE", "0.7")) max_tokens = int(os.getenv("ECHOES_LLM_MAX_TOKENS", "1000")) timeout_seconds = int(os.getenv("ECHOES_LLM_TIMEOUT", "30")) @@ -79,18 +79,20 @@ def validate(self) -> None: f"Invalid provider '{self.provider}'. " "Must be 'stub', 'openai', or 'anthropic'." ) - + if self.provider != "stub" and not self.api_key: raise ValueError(f"API key required for provider '{self.provider}'") - + if self.provider != "stub" and not self.model: - raise ValueError(f"Model identifier required for provider '{self.provider}'") - + raise ValueError( + f"Model identifier required for provider '{self.provider}'" + ) + if not 0.0 <= self.temperature <= 2.0: raise ValueError("Temperature must be between 0.0 and 2.0") - + if self.max_tokens < 1: raise ValueError("max_tokens must be at least 1") - + if self.timeout_seconds < 1: raise ValueError("timeout_seconds must be at least 1") diff --git a/src/gengine/echoes/service/app.py b/src/gengine/echoes/service/app.py index 62c9d2e5..613c5943 100644 --- a/src/gengine/echoes/service/app.py +++ b/src/gengine/echoes/service/app.py @@ -7,7 +7,6 @@ from fastapi import FastAPI, HTTPException from pydantic import BaseModel, Field -from ..core import GameState from ..settings import SimulationConfig, load_simulation_config from ..sim import SimEngine, TickReport from ..sim.engine import EngineNotInitializedError @@ -50,7 +49,9 @@ def create_app( ) -> FastAPI: """Instantiate the FastAPI app backed by a ``SimEngine``.""" - active_config = config or getattr(engine, "config", None) or load_simulation_config() + active_config = ( + config or getattr(engine, "config", None) or load_simulation_config() + ) sim = engine or SimEngine(config=active_config) _ensure_state(sim, auto_world) @@ -111,7 +112,7 @@ def post_focus(payload: FocusRequest) -> FocusResponse: else: focus = sim.set_focus(payload.district_id) except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) + raise HTTPException(status_code=400, detail=str(exc)) from exc digest = sim.state.metadata.get("focus_digest", {}) history = _focus_history(sim, sim.config.focus.history_limit) return FocusResponse(focus=focus, digest=digest, history=history) diff --git a/src/gengine/echoes/service/main.py b/src/gengine/echoes/service/main.py index 05b24ca3..590c4ee0 100644 --- a/src/gengine/echoes/service/main.py +++ b/src/gengine/echoes/service/main.py @@ -6,8 +6,8 @@ import uvicorn -from .app import create_app from ..settings import load_simulation_config +from .app import create_app def main() -> None: # pragma: no cover - thin wrapper around uvicorn diff --git a/src/gengine/echoes/sim/director.py b/src/gengine/echoes/sim/director.py index 2610c0d0..16b287e9 100644 --- a/src/gengine/echoes/sim/director.py +++ b/src/gengine/echoes/sim/director.py @@ -28,7 +28,9 @@ def record( """Build and persist a director-friendly snapshot for ``tick``.""" focus_state = focus_result.focus_state - spatial_weights = self._spatial_preview(focus_state.get("spatial_weights") or []) + spatial_weights = self._spatial_preview( + focus_state.get("spatial_weights") or [] + ) top_ranked = self._ranked_preview(focus_result) payload = { "tick": tick, @@ -117,7 +119,9 @@ def evaluate( focus_center = feed.get("focus_center") hotspots = self._select_hotspots(feed) adjacency = self._adjacency_index(state) - coords = {district.id: district.coordinates for district in state.city.districts} + coords = { + district.id: district.coordinates for district in state.city.districts + } travel_reports: List[Dict[str, Any]] = [] recommended: Dict[str, Any] | None = None @@ -155,7 +159,8 @@ def evaluate( "path": list(travel.get("path") or []), } - # Fall back to the closest hotspot even if the score threshold filtered all entries. + # Fall back to the closest hotspot even if the score threshold + # filtered all entries. if not travel_reports: fallback = self._select_hotspots(feed, enforce_threshold=False) for hotspot in fallback: @@ -171,7 +176,9 @@ def evaluate( "severity": round(float(hotspot.get("severity", 0.0)), 3), "focus_distance": hotspot.get("focus_distance"), "in_focus_ring": hotspot.get("in_focus_ring"), - "travel": self._plan_route(focus_center, target, adjacency, coords), + "travel": self._plan_route( + focus_center, target, adjacency, coords + ), } ) break @@ -207,7 +214,11 @@ def _select_hotspots( district_id = entry.get("district_id") if not district_id: continue - if enforce_threshold and float(entry.get("score", 0.0)) < self._settings.hotspot_score_threshold: + if ( + enforce_threshold + and float(entry.get("score", 0.0)) + < self._settings.hotspot_score_threshold + ): continue hotspots.append(entry) if len(hotspots) >= self._settings.hotspot_limit: @@ -315,7 +326,7 @@ def _path_distance( ) -> float | None: total = 0.0 missing = False - for left, right in zip(path, path[1:]): + for left, right in zip(path, path[1:], strict=False): distance = euclidean_distance(coords.get(left), coords.get(right)) if distance is None: missing = True @@ -336,7 +347,9 @@ def _load_lifecycle(self, state: GameState) -> Dict[str, Dict[str, Any]]: lifecycle[seed_id] = entry return lifecycle - def _save_lifecycle(self, state: GameState, lifecycle: Dict[str, Dict[str, Any]]) -> None: + def _save_lifecycle( + self, state: GameState, lifecycle: Dict[str, Dict[str, Any]] + ) -> None: if lifecycle: state.metadata["story_seed_lifecycle"] = { seed_id: dict(entry) for seed_id, entry in lifecycle.items() @@ -381,7 +394,9 @@ def _record_lifecycle_history( tick: int, ) -> None: history = list(state.metadata.get("story_seed_lifecycle_history") or []) - history.append({"tick": tick, "seed_id": seed_id, "from": previous, "to": new_state}) + history.append( + {"tick": tick, "seed_id": seed_id, "from": previous, "to": new_state} + ) limit = getattr(self._settings, "lifecycle_history_limit", 10) if len(history) > limit: history = history[-limit:] @@ -399,9 +414,15 @@ def _advance_lifecycle_states( if current == "active": expires = entry.get("state_expires") duration = max(0, getattr(self._settings, "seed_active_ticks", 0)) - if duration == 0 or (isinstance(expires, (int, float)) and tick >= int(expires)): - resolve_duration = max(0, getattr(self._settings, "seed_resolve_ticks", 0)) - resolve_expires = tick + resolve_duration if resolve_duration else None + if duration == 0 or ( + isinstance(expires, (int, float)) and tick >= int(expires) + ): + resolve_duration = max( + 0, getattr(self._settings, "seed_resolve_ticks", 0) + ) + resolve_expires = ( + tick + resolve_duration if resolve_duration else None + ) self._transition_state( state, lifecycle, @@ -413,7 +434,9 @@ def _advance_lifecycle_states( elif current == "resolving": expires = entry.get("state_expires") duration = max(0, getattr(self._settings, "seed_resolve_ticks", 0)) - if duration == 0 or (isinstance(expires, (int, float)) and tick >= int(expires)): + if duration == 0 or ( + isinstance(expires, (int, float)) and tick >= int(expires) + ): quiet_span = max(0, getattr(self._settings, "seed_quiet_ticks", 0)) quiet_until = tick + quiet_span if quiet_span else None self._transition_state( @@ -439,7 +462,11 @@ def _advance_lifecycle_states( seed_id, "primed", tick, - updates={"state_expires": None, "quiet_until": None, "cooldown_until": None}, + updates={ + "state_expires": None, + "quiet_until": None, + "cooldown_until": None, + }, ) for seed_id in list(lifecycle.keys()): @@ -549,13 +576,17 @@ def _match_story_seeds( for entry in hotspots if entry.get("district_id") } - district_lookup = {district.id: district.name for district in state.city.districts} + district_lookup = { + district.id: district.name for district in state.city.districts + } new_events: List[Dict[str, Any]] = [] quiet_until = self._normalize_quiet_timer(state, tick) blocked_reasons: List[str] = [] max_active = max(1, getattr(self._settings, "max_active_seeds", 1)) active_count = sum( - 1 for entry in lifecycle.values() if entry.get("state") in {"active", "resolving"} + 1 + for entry in lifecycle.values() + if entry.get("state") in {"active", "resolving"} ) self._sync_contexts_with_lifecycle(contexts, lifecycle) @@ -575,7 +606,9 @@ def _match_story_seeds( blocked_reasons.append("max_active") break seed_quiet_until = entry.get("quiet_until") - if isinstance(seed_quiet_until, (int, float)) and tick < int(seed_quiet_until): + if isinstance(seed_quiet_until, (int, float)) and tick < int( + seed_quiet_until + ): blocked_reasons.append("seed_quiet") continue last_tick = cooldowns.get(seed.id) @@ -676,8 +709,16 @@ def _match_story_seeds( if len(active_matches) > self._settings.story_seed_limit: active_matches = active_matches[: self._settings.story_seed_limit] - cooldowns = {seed_id: value for seed_id, value in cooldowns.items() if seed_id in state.story_seeds} - contexts = {seed_id: payload for seed_id, payload in contexts.items() if seed_id in state.story_seeds} + cooldowns = { + seed_id: value + for seed_id, value in cooldowns.items() + if seed_id in state.story_seeds + } + contexts = { + seed_id: payload + for seed_id, payload in contexts.items() + if seed_id in state.story_seeds + } if active_matches: state.metadata["story_seeds_active"] = active_matches @@ -745,9 +786,8 @@ def _match_hotspot( if severity < trigger.min_severity: continue focus_distance = entry.get("focus_distance") - if ( - trigger.max_focus_distance is not None - and (focus_distance is None or focus_distance > trigger.max_focus_distance) + if trigger.max_focus_distance is not None and ( + focus_distance is None or focus_distance > trigger.max_focus_distance ): continue return { @@ -778,7 +818,9 @@ def _build_director_event( "stakes": seed.stakes, "scope": seed.scope, "district_id": district_id, - "district_name": district_lookup.get(district_id, district_id) if district_id else None, + "district_name": district_lookup.get(district_id, district_id) + if district_id + else None, "reason": trigger_match.get("reason"), "score": trigger_match.get("score"), "severity": trigger_match.get("severity"), @@ -796,7 +838,9 @@ def _build_director_event( event["travel_hint"] = seed.travel_hint.model_dump() return event - def _resolve_agents(self, state: GameState, agent_ids: Sequence[str]) -> List[Dict[str, Any]]: + def _resolve_agents( + self, state: GameState, agent_ids: Sequence[str] + ) -> List[Dict[str, Any]]: resolved: List[Dict[str, Any]] = [] for agent_id in agent_ids[:3]: agent = state.agents.get(agent_id) @@ -814,7 +858,9 @@ def _resolve_agents(self, state: GameState, agent_ids: Sequence[str]) -> List[Di ) return resolved - def _resolve_factions(self, state: GameState, faction_ids: Sequence[str]) -> List[Dict[str, Any]]: + def _resolve_factions( + self, state: GameState, faction_ids: Sequence[str] + ) -> List[Dict[str, Any]]: resolved: List[Dict[str, Any]] = [] for faction_id in faction_ids[:3]: faction = state.factions.get(faction_id) diff --git a/src/gengine/echoes/sim/engine.py b/src/gengine/echoes/sim/engine.py index 9fcd5b85..3910cab4 100644 --- a/src/gengine/echoes/sim/engine.py +++ b/src/gengine/echoes/sim/engine.py @@ -3,8 +3,8 @@ from __future__ import annotations import logging -from collections import deque import math +from collections import deque from pathlib import Path from time import perf_counter from typing import Any, Deque, Literal, Sequence @@ -13,16 +13,25 @@ from ..core import GameState from ..persistence import load_snapshot from ..settings import SimulationConfig, load_simulation_config -from ..systems import AgentSystem, EconomySystem, EnvironmentSystem, FactionSystem, ProgressionSystem +from ..systems import ( + AgentSystem, + EconomySystem, + EnvironmentSystem, + FactionSystem, + ProgressionSystem, +) from ..systems.progression import ( PerAgentProgressionSettings as SystemPerAgentSettings, +) +from ..systems.progression import ( ProgressionSettings as SystemProgressionSettings, ) from .director import DirectorBridge, NarrativeDirector from .explanations import ExplanationsManager from .focus import FocusManager from .post_mortem import generate_post_mortem_summary -from .tick import TickReport, advance_ticks as _advance_ticks +from .tick import TickReport +from .tick import advance_ticks as _advance_ticks ViewName = Literal["summary", "snapshot", "district", "post-mortem"] @@ -57,7 +66,9 @@ def __init__( settings=self._create_progression_settings(), per_agent_settings=self._create_per_agent_settings(), ) - self._tick_history: Deque[float] = deque(maxlen=self._config.profiling.history_window) + self._tick_history: Deque[float] = deque( + maxlen=self._config.profiling.history_window + ) def _create_progression_settings(self) -> SystemProgressionSettings: """Convert config progression settings to system settings.""" @@ -127,14 +138,14 @@ def initialize_state( return self.state # ------------------------------------------------------------------ - def advance_ticks(self, count: int = 1, *, seed: int | None = None) -> Sequence[TickReport]: + def advance_ticks( + self, count: int = 1, *, seed: int | None = None + ) -> Sequence[TickReport]: """Advance simulation time by ``count`` ticks using the tick driver.""" limit = self._config.limits.engine_max_ticks if count > limit: - raise ValueError( - f"Requested {count} ticks exceeds engine limit of {limit}" - ) + raise ValueError(f"Requested {count} ticks exceeds engine limit of {limit}") start = perf_counter() reports = _advance_ticks( @@ -245,7 +256,9 @@ def explain_agent(self, agent_id: str, *, lookback: int = 10) -> dict[str, Any]: self.state, agent_id, lookback=lookback ) - def explain_district(self, district_id: str, *, lookback: int = 10) -> dict[str, Any]: + def explain_district( + self, district_id: str, *, lookback: int = 10 + ) -> dict[str, Any]: """Explain changes in a district.""" return self._explanations_manager.explain_district( self.state, district_id, lookback=lookback @@ -316,7 +329,9 @@ def _record_profiling(self, reports: Sequence[TickReport]) -> None: slowest_entry: dict[str, float] | None = None if last_subsystems: profiling_payload["last_subsystem_ms"] = last_subsystems - slowest_name, slowest_value = max(last_subsystems.items(), key=lambda item: item[1]) + slowest_name, slowest_value = max( + last_subsystems.items(), key=lambda item: item[1] + ) slowest_entry = { "name": slowest_name, "ms": slowest_value, @@ -353,7 +368,9 @@ def _district_view(self, district_id: str | None) -> dict[str, Any]: "name": district.name, "population": district.population, "modifiers": district.modifiers.model_dump(), - "coordinates": district.coordinates.model_dump() if district.coordinates else None, + "coordinates": district.coordinates.model_dump() + if district.coordinates + else None, "adjacent": list(district.adjacent), } - raise ValueError(f"Unknown district '{district_id}'") \ No newline at end of file + raise ValueError(f"Unknown district '{district_id}'") diff --git a/src/gengine/echoes/sim/explanations.py b/src/gengine/echoes/sim/explanations.py index 83ab5ae8..722f6657 100644 --- a/src/gengine/echoes/sim/explanations.py +++ b/src/gengine/echoes/sim/explanations.py @@ -218,7 +218,9 @@ def record_tick( CausalEvent( tick=tick, category=CausalCategory.FACTION, - description=f"{name} {direction} {abs(delta):.3f} legitimacy", + description=( + f"{name} {direction} {abs(delta):.3f} legitimacy" + ), entity_id=faction_id, entity_name=name, metric="legitimacy", @@ -233,7 +235,9 @@ def record_tick( for action in agent_actions: agent_id = action.get("agent_id") agent = state.agents.get(agent_id) if agent_id else None - agent_name = action.get("agent_name") or (agent.name if agent else agent_id) + agent_name = action.get("agent_name") or ( + agent.name if agent else agent_id + ) intent = action.get("intent", "acted") target = action.get("target_name") or action.get("target") @@ -293,7 +297,9 @@ def record_tick( for action in faction_actions: faction_id = action.get("faction_id") faction = state.factions.get(faction_id) if faction_id else None - faction_name = action.get("faction_name") or (faction.name if faction else faction_id) + faction_name = action.get("faction_name") or ( + faction.name if faction else faction_id + ) action_type = action.get("action", "acted") target = action.get("target_name") or action.get("target") @@ -381,7 +387,9 @@ def record_tick( "district": district, "stakes": stakes, "agents": [a.get("name") for a in event.get("agents", [])], - "factions": [f.get("name") for f in event.get("factions", [])], + "factions": [ + f.get("name") for f in event.get("factions", []) + ], }, ) ) @@ -406,7 +414,7 @@ def record_tick( # Add to timeline self._timeline.append(entry) if len(self._timeline) > self._history_limit: - self._timeline = self._timeline[-self._history_limit:] + self._timeline = self._timeline[-self._history_limit :] # Index events by entity for event in events: @@ -429,7 +437,7 @@ def _persist_to_metadata(self, state: GameState, entry: TimelineEntry) -> None: history = list(state.metadata.get("explanation_timeline_history") or []) history.append(entry.to_dict()) if len(history) > self._history_limit: - history = history[-self._history_limit:] + history = history[-self._history_limit :] state.metadata["explanation_timeline_history"] = history # Store agent reasoning summaries @@ -472,7 +480,10 @@ def explain_metric( for event in relevant_events: if event.causes: causes.extend(event.causes) - if event.category == CausalCategory.ECONOMY and metric in ("unrest", "stability"): + if event.category == CausalCategory.ECONOMY and metric in ( + "unrest", + "stability", + ): causes.append(event.description) if event.category == CausalCategory.FACTION and event.delta: causes.append(event.description) @@ -486,7 +497,9 @@ def explain_metric( return { "metric": metric, - "current_value": round(current_value, 3) if current_value is not None else None, + "current_value": round(current_value, 3) + if current_value is not None + else None, "total_delta": round(total_delta, 4), "event_count": len(relevant_events), "causes": list(set(causes))[:10], @@ -510,16 +523,14 @@ def explain_faction( # Calculate legitimacy trend legitimacy_deltas = [ - e.delta for e in faction_events + e.delta + for e in faction_events if e.delta is not None and e.metric == "legitimacy" ] trend = sum(legitimacy_deltas) if legitimacy_deltas else 0.0 # Get actions - actions = [ - e for e in faction_events - if e.metadata.get("action") - ] + actions = [e for e in faction_events if e.metadata.get("action")] return { "faction_id": faction_id, @@ -612,14 +623,12 @@ def explain_district( # Get story seeds affecting this district story_seeds = [ - e for e in district_events - if e.category == CausalCategory.STORY_SEED + e for e in district_events if e.category == CausalCategory.STORY_SEED ] # Get faction activities in this district faction_activity = [ - e for e in district_events - if e.category == CausalCategory.FACTION + e for e in district_events if e.category == CausalCategory.FACTION ] return { @@ -674,7 +683,10 @@ def get_why_summary( # Check for district queries for district in state.city.districts: - if district.id.lower() in query_lower or district.name.lower() in query_lower: + if ( + district.id.lower() in query_lower + or district.name.lower() in query_lower + ): return self.explain_district(state, district.id) # Default: return recent key changes @@ -685,7 +697,10 @@ def get_why_summary( return { "query": query, "matched": False, - "suggestion": "Try asking about stability, unrest, pollution, a faction name, agent name, or district name", + "suggestion": ( + "Try asking about stability, unrest, pollution, " + "a faction name, agent name, or district name" + ), "recent_changes": recent_changes[-10:], } @@ -709,9 +724,11 @@ def _infer_stability_causes(self, state: GameState) -> List[str]: causes.append(f"unrest in {', '.join(high_unrest_districts[:2])}") # Check faction legitimacy - for faction_id, faction in state.factions.items(): + for _faction_id, faction in state.factions.items(): if faction.legitimacy < 0.3: - causes.append(f"{faction.name} legitimacy crisis ({faction.legitimacy:.2f})") + causes.append( + f"{faction.name} legitimacy crisis ({faction.legitimacy:.2f})" + ) elif faction.legitimacy > 0.8: causes.append(f"{faction.name} dominance ({faction.legitimacy:.2f})") diff --git a/src/gengine/echoes/sim/focus.py b/src/gengine/echoes/sim/focus.py index 4d9cb8b1..5c52cf99 100644 --- a/src/gengine/echoes/sim/focus.py +++ b/src/gengine/echoes/sim/focus.py @@ -153,7 +153,8 @@ def record_digest( entry.to_display() for entry in result.suppressed[:preview_limit] ], "archive": [ - entry.to_display() for entry in result.archive[: self._settings.history_limit] + entry.to_display() + for entry in result.archive[: self._settings.history_limit] ], "ranked_archive": [ ranked.to_payload() @@ -215,7 +216,9 @@ def _build_focus_payload( "spatial_metrics": metrics, } - def _resolve_center(self, districts: Sequence[District], district_id: str | None) -> District: + def _resolve_center( + self, districts: Sequence[District], district_id: str | None + ) -> District: lookup = {district.id: district for district in districts} if district_id and district_id in lookup: return lookup[district_id] @@ -274,7 +277,9 @@ def _rank_events( ring = focus_state.get("ring") or [] ranked: List[RankedEvent] = [] for entry in events: - severity = self._scope_severity(entry.scope) + self._message_bonus(entry.message) + severity = self._scope_severity(entry.scope) + self._message_bonus( + entry.message + ) severity = max(0.1, min(1.5, severity)) distance = self._focus_distance(entry.district_id, ring) distance_penalty = max(0.2, 1.0 - 0.15 * min(distance, 5)) @@ -349,10 +354,13 @@ def _score_districts( adjacency = set(center.adjacent) distances = self._distance_lookup(center.coordinates, districts) raw_max_distance = max(distances.values(), default=0.0) - normalized_max = raw_max_distance if raw_max_distance > 0 else self._settings.spatial_falloff + normalized_max = ( + raw_max_distance if raw_max_distance > 0 else self._settings.spatial_falloff + ) total_weight = max( 0.001, - self._settings.spatial_population_weight + self._settings.spatial_distance_weight, + self._settings.spatial_population_weight + + self._settings.spatial_distance_weight, ) results: List[Dict[str, object]] = [] @@ -382,7 +390,9 @@ def _score_districts( } ) - results.sort(key=lambda item: (item["score"], item["population_rank"]), reverse=True) + results.sort( + key=lambda item: (item["score"], item["population_rank"]), reverse=True + ) metrics = { "population_weight": self._settings.spatial_population_weight, "distance_weight": self._settings.spatial_distance_weight, diff --git a/src/gengine/echoes/sim/post_mortem.py b/src/gengine/echoes/sim/post_mortem.py index 8089c525..a1ae097d 100644 --- a/src/gengine/echoes/sim/post_mortem.py +++ b/src/gengine/echoes/sim/post_mortem.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass from typing import Any, Dict, List, Mapping, Sequence from ..core import GameState @@ -27,7 +27,9 @@ def to_dict(self) -> Dict[str, Any]: def generate_post_mortem_summary(state: GameState) -> Dict[str, Any]: """Build a deterministic recap that downstream tools can diff.""" - history: List[Mapping[str, Any]] = list(state.metadata.get("director_history") or []) + history: List[Mapping[str, Any]] = list( + state.metadata.get("director_history") or [] + ) events: List[Dict[str, Any]] = list(state.metadata.get("director_events") or []) environment = _environment_snapshot(state) env_trend = _environment_trend(history, environment) @@ -70,14 +72,26 @@ def _environment_trend( end_env = dict(fallback) trend: Dict[str, Any] = { "start": { - "stability": round(float(start_env.get("stability", fallback.get("stability", 0.0))), 3), - "unrest": round(float(start_env.get("unrest", fallback.get("unrest", 0.0))), 3), - "pollution": round(float(start_env.get("pollution", fallback.get("pollution", 0.0))), 3), + "stability": round( + float(start_env.get("stability", fallback.get("stability", 0.0))), 3 + ), + "unrest": round( + float(start_env.get("unrest", fallback.get("unrest", 0.0))), 3 + ), + "pollution": round( + float(start_env.get("pollution", fallback.get("pollution", 0.0))), 3 + ), }, "end": { - "stability": round(float(end_env.get("stability", fallback.get("stability", 0.0))), 3), - "unrest": round(float(end_env.get("unrest", fallback.get("unrest", 0.0))), 3), - "pollution": round(float(end_env.get("pollution", fallback.get("pollution", 0.0))), 3), + "stability": round( + float(end_env.get("stability", fallback.get("stability", 0.0))), 3 + ), + "unrest": round( + float(end_env.get("unrest", fallback.get("unrest", 0.0))), 3 + ), + "pollution": round( + float(end_env.get("pollution", fallback.get("pollution", 0.0))), 3 + ), }, } trend["delta"] = { @@ -87,7 +101,9 @@ def _environment_trend( return trend -def _faction_trends(history: List[Mapping[str, Any]], state: GameState) -> List[Dict[str, Any]]: +def _faction_trends( + history: List[Mapping[str, Any]], state: GameState +) -> List[Dict[str, Any]]: baseline: Dict[str, float] = {} latest: Dict[str, float] = {} for entry in history: @@ -97,9 +113,15 @@ def _faction_trends(history: List[Mapping[str, Any]], state: GameState) -> List[ if snapshot: latest = {k: round(float(v), 4) for k, v in snapshot.items()} if not baseline: - baseline = {faction_id: round(faction.legitimacy, 4) for faction_id, faction in state.factions.items()} + baseline = { + faction_id: round(faction.legitimacy, 4) + for faction_id, faction in state.factions.items() + } if not latest: - latest = {faction_id: round(faction.legitimacy, 4) for faction_id, faction in state.factions.items()} + latest = { + faction_id: round(faction.legitimacy, 4) + for faction_id, faction in state.factions.items() + } entries: List[Dict[str, Any]] = [] for faction_id in sorted(set(baseline) | set(latest)): start_value = baseline.get(faction_id, 0.0) @@ -135,7 +157,9 @@ def _featured_events(events: List[Dict[str, Any]]) -> List[Dict[str, Any]]: return normalized -def _story_seed_recap(state: GameState, events: List[Dict[str, Any]]) -> List[Dict[str, Any]]: +def _story_seed_recap( + state: GameState, events: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: lifecycle = state.metadata.get("story_seed_lifecycle") or {} history = list(state.metadata.get("story_seed_lifecycle_history") or []) history_index: Dict[str, Dict[str, Any]] = {} @@ -150,12 +174,22 @@ def _story_seed_recap(state: GameState, events: List[Dict[str, Any]]) -> List[Di event_index[seed_id] = event recaps: List[Dict[str, Any]] = [] for seed_id, entry in lifecycle.items(): - recaps.append(_seed_entry(seed_id, entry, state, history_index.get(seed_id), event_index.get(seed_id))) + recaps.append( + _seed_entry( + seed_id, + entry, + state, + history_index.get(seed_id), + event_index.get(seed_id), + ) + ) # Include seeds that only live in history (e.g., archived + pruned lifecycle) for seed_id, entry in history_index.items(): if any(rec["seed_id"] == seed_id for rec in recaps): continue - recaps.append(_seed_entry(seed_id, entry, state, entry, event_index.get(seed_id))) + recaps.append( + _seed_entry(seed_id, entry, state, entry, event_index.get(seed_id)) + ) recaps.sort(key=lambda entry: entry.get("last_activity_tick", -1), reverse=True) return recaps[:5] @@ -203,7 +237,9 @@ def _seed_entry( summary["district_id"] = district_id if event_reason: summary["reason"] = event_reason - cooldown = payload.get("cooldown_remaining") or _int_or_none(payload.get("cooldown_until")) + cooldown = payload.get("cooldown_remaining") or _int_or_none( + payload.get("cooldown_until") + ) if isinstance(cooldown, (int, float)): summary["cooldown_remaining"] = int(max(cooldown, 0)) return summary @@ -234,8 +270,10 @@ def _post_mortem_notes( swing = faction_trends[0] if swing["delta"]: direction = "gained" if swing["delta"] > 0 else "lost" + delta_abs = abs(swing["delta"]) notes.append( - f"Faction {swing['faction_id']} {direction} {abs(swing['delta']):.3f} legitimacy (start {swing['start']:.3f} → end {swing['end']:.3f})." + f"Faction {swing['faction_id']} {direction} {delta_abs:.3f} " + f"legitimacy (start {swing['start']:.3f} → end {swing['end']:.3f})." ) archived = [seed for seed in seeds if seed.get("state") == "archived"] if archived: @@ -243,4 +281,4 @@ def _post_mortem_notes( if len(archived) > 2: names = f"{names} (+{len(archived) - 2} more)" notes.append(f"Archived story seeds: {names}.") - return notes \ No newline at end of file + return notes diff --git a/src/gengine/echoes/sim/tick.py b/src/gengine/echoes/sim/tick.py index d83f0b22..08862260 100644 --- a/src/gengine/echoes/sim/tick.py +++ b/src/gengine/echoes/sim/tick.py @@ -12,8 +12,7 @@ from ..settings import LodSettings, ProfilingSettings from .director import DirectorBridge, NarrativeDirector from .explanations import ExplanationsManager -from .focus import FocusBudgetResult, FocusManager, NarrativeEvent - +from .focus import FocusManager, NarrativeEvent logger = logging.getLogger("gengine.echoes.sim.tick") @@ -82,7 +81,9 @@ def run( rng = random.Random(rng_seed) scale = lod.scale if lod else 1.0 event_budget = lod.max_events_per_tick if lod else None - capture_timings = profiling.capture_subsystems if profiling is not None else True + capture_timings = ( + profiling.capture_subsystems if profiling is not None else True + ) reports: List[TickReport] = [] for _ in range(count): @@ -119,7 +120,8 @@ def _execute_tick( event_entries: List[NarrativeEvent] = [] anomalies: List[str] = [] prev_legitimacy = { - faction_id: faction.legitimacy for faction_id, faction in state.factions.items() + faction_id: faction.legitimacy + for faction_id, faction in state.factions.items() } prev_environment = { "stability": state.environment.stability, @@ -191,7 +193,8 @@ def _execute_tick( if env_result is not None: if getattr(env_result, "events", None): event_entries.extend( - NarrativeEvent(message, None, "environment") for message in env_result.events + NarrativeEvent(message, None, "environment") + for message in env_result.events ) if hasattr(env_result, "to_dict"): impact_payload = env_result.to_dict() @@ -204,7 +207,9 @@ def _execute_tick( if capture_timings: timings["environment_ms"] = (perf_counter() - segment_start) * 1000 - focus_result = focus_manager.curate(state, event_entries, event_budget=event_budget) + focus_result = focus_manager.curate( + state, event_entries, event_budget=event_budget + ) visible_events = [entry.to_display() for entry in focus_result.visible] archive_events = [entry.to_display() for entry in focus_result.archive] suppressed_events = [entry.to_display() for entry in focus_result.suppressed] @@ -227,7 +232,9 @@ def _execute_tick( # Record explanations for causal queries faction_deltas = _legitimacy_delta(prev_legitimacy, state) environment_delta = { - metric: round(getattr(state.environment, metric) - prev_environment[metric], 4) + metric: round( + getattr(state.environment, metric) - prev_environment[metric], 4 + ) for metric in prev_environment } if explanations_manager is not None: @@ -281,7 +288,7 @@ def _invoke_subsystem( segment_start = perf_counter() try: return system.tick(**kwargs) - except Exception as exc: # pragma: no cover - defensive safeguard + except Exception: # pragma: no cover - defensive safeguard anomalies.append(f"{label}_error") logger.exception("%s system failed", label) return default @@ -346,7 +353,8 @@ def _update_resources( if abs(new_value - stock.current) > 3: events.append( NarrativeEvent( - f"{district.name} {stock.type} adjusted to {int(new_value)} units", + f"{district.name} {stock.type} adjusted " + f"to {int(new_value)} units", district_id=district.id, scope="resources", ) @@ -427,11 +435,15 @@ def _update_environment( if env.unrest > 0.7: events.append( - NarrativeEvent("Civic tension is rising across the city", scope="environment") + NarrativeEvent( + "Civic tension is rising across the city", scope="environment" + ) ) if env.pollution > 0.7: events.append( - NarrativeEvent("Pollution breaches critical thresholds", scope="environment") + NarrativeEvent( + "Pollution breaches critical thresholds", scope="environment" + ) ) if env.stability < 0.4: events.append(NarrativeEvent("Governance stability wanes", scope="environment")) @@ -460,7 +472,9 @@ def _district_snapshot(state: GameState) -> List[dict[str, object]]: "prosperity": district.modifiers.prosperity, "security": district.modifiers.security, "adjacent": list(district.adjacent), - "coordinates": district.coordinates.model_dump() if district.coordinates else None, + "coordinates": district.coordinates.model_dump() + if district.coordinates + else None, } ) return entries @@ -479,7 +493,9 @@ def _mean_revert(value: float, noise: float, scale: float, *, drift: float) -> f return _clamp(value + (0.5 - value) * drift + noise * scale) -def _summarize_agent_actions(actions: Sequence, district_ids: set[str]) -> List[NarrativeEvent]: +def _summarize_agent_actions( + actions: Sequence, district_ids: set[str] +) -> List[NarrativeEvent]: summaries: List[NarrativeEvent] = [] for action in actions: agent_name = getattr(action, "agent_name", getattr(action, "agent_id", "Agent")) @@ -500,7 +516,9 @@ def _summarize_agent_actions(actions: Sequence, district_ids: set[str]) -> List[ message = f"{agent_name} files a report on {target}" else: message = f"{agent_name} acts in {target}" - summaries.append(NarrativeEvent(message, district_id=district_id, scope="agent")) + summaries.append( + NarrativeEvent(message, district_id=district_id, scope="agent") + ) return summaries @@ -521,7 +539,9 @@ def _summarize_faction_actions(actions: Sequence) -> List[NarrativeEvent]: message = f"{name} undermines {target}" else: message = f"{name} acts strategically" - summaries.append(NarrativeEvent(message, district_id=district_id, scope="faction")) + summaries.append( + NarrativeEvent(message, district_id=district_id, scope="faction") + ) return summaries @@ -540,7 +560,10 @@ def _summarize_economy(report) -> List[NarrativeEvent]: def _legitimacy_snapshot(state: GameState) -> Dict[str, float]: - return {faction_id: round(faction.legitimacy, 4) for faction_id, faction in state.factions.items()} + return { + faction_id: round(faction.legitimacy, 4) + for faction_id, faction in state.factions.items() + } def _legitimacy_delta(previous: Dict[str, float], state: GameState) -> Dict[str, float]: diff --git a/src/gengine/echoes/systems/__init__.py b/src/gengine/echoes/systems/__init__.py index 070494f1..0b9d5b53 100644 --- a/src/gengine/echoes/systems/__init__.py +++ b/src/gengine/echoes/systems/__init__.py @@ -1,9 +1,9 @@ """Gameplay subsystems for Echoes of Emergence.""" from .agents import AgentIntent, AgentSystem -from .economy import EconomySystem, EconomyReport -from .factions import FactionAction, FactionSystem +from .economy import EconomyReport, EconomySystem from .environment import EnvironmentImpact, EnvironmentSystem +from .factions import FactionAction, FactionSystem from .progression import ( PerAgentProgressionSettings, ProgressionEvent, @@ -12,11 +12,11 @@ ) __all__ = [ - "AgentIntent", - "AgentSystem", - "FactionAction", - "FactionSystem", - "EconomySystem", + "AgentIntent", + "AgentSystem", + "FactionAction", + "FactionSystem", + "EconomySystem", "EconomyReport", "EnvironmentImpact", "EnvironmentSystem", diff --git a/src/gengine/echoes/systems/agents.py b/src/gengine/echoes/systems/agents.py index f15fa672..5aedd0ca 100644 --- a/src/gengine/echoes/systems/agents.py +++ b/src/gengine/echoes/systems/agents.py @@ -121,7 +121,9 @@ def _decide( options = self._calculate_scores(agent, district, faction, state) if force_strategic: - strategic_options = [option for option in options if option[0] in self._STRATEGIC_INTENTS] + strategic_options = [ + option for option in options if option[0] in self._STRATEGIC_INTENTS + ] if strategic_options: options = strategic_options @@ -171,7 +173,9 @@ def _build_intent( target_name = district.name else: target = district.id if district else (faction.id if faction else "city") - target_name = district.name if district else (faction.name if faction else "city") + target_name = ( + district.name if district else (faction.name if faction else "city") + ) detail = "gathers intelligence on city status" return AgentIntent( agent_id=agent.id, @@ -180,4 +184,4 @@ def _build_intent( target=target, target_name=target_name, detail=detail, - ) \ No newline at end of file + ) diff --git a/src/gengine/echoes/systems/economy.py b/src/gengine/echoes/systems/economy.py index 6a5fa5ee..e93d2150 100644 --- a/src/gengine/echoes/systems/economy.py +++ b/src/gengine/echoes/systems/economy.py @@ -4,7 +4,7 @@ import random from dataclasses import dataclass -from typing import Dict, List +from typing import Dict from ..core import GameState from ..core.models import District, ResourceStock @@ -20,7 +20,9 @@ class EconomyReport: def to_dict(self) -> Dict[str, Dict[str, float | int]]: return { - "prices": {resource: round(price, 4) for resource, price in self.prices.items()}, + "prices": { + resource: round(price, 4) for resource, price in self.prices.items() + }, "shortages": self.shortages.copy(), } @@ -43,7 +45,10 @@ def tick(self, state: GameState, *, rng: random.Random) -> EconomyReport: # ------------------------------------------------------------------ def _rebalance_district(self, district: District, rng: random.Random) -> None: - demand_mod = 1.0 + district.modifiers.prosperity * self._settings.demand_prosperity_weight + demand_mod = ( + 1.0 + + district.modifiers.prosperity * self._settings.demand_prosperity_weight + ) for stock in district.resources.values(): regen_factor = max( 0.0, self._settings.regen_scale + rng.uniform(-0.05, 0.05) @@ -53,13 +58,17 @@ def _rebalance_district(self, district: District, rng: random.Random) -> None: delta = production - consumption stock.current = int(_clamp(stock.current + delta, 0, stock.capacity)) - def _resource_demand(self, stock: ResourceStock, district: District, demand_mod: float) -> float: + def _resource_demand( + self, stock: ResourceStock, district: District, demand_mod: float + ) -> float: population_factor = max( district.population / self._settings.demand_population_scale, 0.3, ) base = self._resource_weight(stock.type) - unrest_penalty = 1.0 + district.modifiers.unrest * self._settings.demand_unrest_weight + unrest_penalty = ( + 1.0 + district.modifiers.unrest * self._settings.demand_unrest_weight + ) return base * population_factor * demand_mod * unrest_penalty def _resource_weight(self, resource_type: str) -> float: diff --git a/src/gengine/echoes/systems/environment.py b/src/gengine/echoes/systems/environment.py index 07ca98c0..308a458c 100644 --- a/src/gengine/echoes/systems/environment.py +++ b/src/gengine/echoes/systems/environment.py @@ -104,7 +104,9 @@ def tick( biodiversity_snapshot = { "value": round(env.biodiversity, 4), - "delta": round(scarcity_biodiversity_delta + biodiversity_recovery_delta, 6), + "delta": round( + scarcity_biodiversity_delta + biodiversity_recovery_delta, 6 + ), "scarcity_delta": round(scarcity_biodiversity_delta, 6), "recovery_delta": round(biodiversity_recovery_delta, 6), } @@ -130,7 +132,9 @@ def _scarcity_pressure(self, report: EconomyReport) -> float: if not report.shortages: return 0.0 cap = float(self._settings.scarcity_pressure_cap) - capped = [min(float(duration), cap) / cap for duration in report.shortages.values()] + capped = [ + min(float(duration), cap) / cap for duration in report.shortages.values() + ] return sum(capped) def _apply_scarcity_pressure( @@ -156,13 +160,17 @@ def _apply_scarcity_pressure( unrest_delta = pressure * self._settings.district_unrest_weight pollution_delta = pressure * self._settings.district_pollution_weight if unrest_delta: - district.modifiers.unrest = _clamp(district.modifiers.unrest + unrest_delta) + district.modifiers.unrest = _clamp( + district.modifiers.unrest + unrest_delta + ) _record_delta(district_deltas, district.id, "unrest", unrest_delta) if pollution_delta: district.modifiers.pollution = _clamp( district.modifiers.pollution + pollution_delta ) - _record_delta(district_deltas, district.id, "pollution", pollution_delta) + _record_delta( + district_deltas, district.id, "pollution", pollution_delta + ) return biodiversity_delta def _apply_diffusion( @@ -190,7 +198,9 @@ def _apply_diffusion( neighbor_avg = _neighbor_average(district, lookup) target = avg_pollution if neighbor_avg is not None: - target = (neighbor_bias * neighbor_avg) + ((1.0 - neighbor_bias) * avg_pollution) + target = (neighbor_bias * neighbor_avg) + ( + (1.0 - neighbor_bias) * avg_pollution + ) delta = (target - district.modifiers.pollution) * rate if abs(delta) < min_delta: continue @@ -214,7 +224,12 @@ def _apply_diffusion( ) ) - samples = [entry for _, entry in sorted(top_deltas, key=lambda item: item[0], reverse=True)[:3]] + samples = [ + entry + for _, entry in sorted(top_deltas, key=lambda item: item[0], reverse=True)[ + :3 + ] + ] avg_pollution = sum(d.modifiers.pollution for d in districts) / len(districts) extremes = _pollution_extremes(districts) return applied, avg_pollution, extremes, samples @@ -257,7 +272,8 @@ def _apply_faction_effects( ) verb = "relief" if delta < 0 else "surge" events.append( - f"{action.faction_name} {descriptor} pollution in {district.name} ({verb})" + f"{action.faction_name} {descriptor} pollution " + f"in {district.name} ({verb})" ) return events, effects @@ -294,7 +310,9 @@ def _record_delta( def _neighbor_average(district: District, lookup: Dict[str, District]) -> float | None: - neighbors = [lookup[neighbor] for neighbor in district.adjacent if neighbor in lookup] + neighbors = [ + lookup[neighbor] for neighbor in district.adjacent if neighbor in lookup + ] if not neighbors: return None return sum(neighbor.modifiers.pollution for neighbor in neighbors) / len(neighbors) diff --git a/src/gengine/echoes/systems/factions.py b/src/gengine/echoes/systems/factions.py index af9b4284..42f4a9b7 100644 --- a/src/gengine/echoes/systems/factions.py +++ b/src/gengine/echoes/systems/factions.py @@ -157,9 +157,7 @@ def _execute_action( unrest_delta = -0.05 security_delta = 0.03 prosperity_delta = 0.04 - district.modifiers.unrest = _clamp( - district.modifiers.unrest + unrest_delta - ) + district.modifiers.unrest = _clamp(district.modifiers.unrest + unrest_delta) district.modifiers.security = _clamp( district.modifiers.security + security_delta ) @@ -194,9 +192,7 @@ def _execute_action( district = self._select_district(rival, districts) if district is not None: detail = f"agitates unrest in {district.name}" - district.modifiers.unrest = _clamp( - district.modifiers.unrest + 0.02 - ) + district.modifiers.unrest = _clamp(district.modifiers.unrest + 0.02) district_id = district.id else: district_id = None @@ -239,7 +235,9 @@ def _strongest_rival( faction: Faction, factions: Dict[str, Faction], ) -> Faction | None: - rivals = [candidate for candidate in factions.values() if candidate.id != faction.id] + rivals = [ + candidate for candidate in factions.values() if candidate.id != faction.id + ] if not rivals: return None return max(rivals, key=lambda rival: rival.legitimacy) diff --git a/src/gengine/echoes/systems/progression.py b/src/gengine/echoes/systems/progression.py index b16863be..803f4c32 100644 --- a/src/gengine/echoes/systems/progression.py +++ b/src/gengine/echoes/systems/progression.py @@ -15,12 +15,8 @@ from ..core import GameState from ..core.progression import ( - AccessTier, - AgentProgressionState, - AgentSpecialization, ProgressionState, SkillDomain, - SPECIALIZATION_DOMAIN_MAP, calculate_agent_modifier, calculate_success_modifier, ) @@ -31,7 +27,9 @@ class ProgressionEvent: """Records a progression change during a tick.""" tick: int - event_type: str # "skill_gain", "reputation_change", "tier_unlock", "agent_progression" + event_type: ( + str # "skill_gain", "reputation_change", "tier_unlock", "agent_progression" + ) domain: Optional[str] = None faction_id: Optional[str] = None agent_id: Optional[str] = None @@ -238,7 +236,6 @@ def _process_agent_action( exp_amount = base_exp * multiplier * self._settings.base_experience_rate # Add experience - old_level = progression.get_skill_level(domain) old_tier = progression.access_tier levels_gained = progression.add_skill_experience(domain, exp_amount) @@ -252,7 +249,10 @@ def _process_agent_action( domain=domain.value, amount=exp_amount, new_level=new_level, - detail=f"{domain.value.title()} skill increased to level {new_level}", + detail=( + f"{domain.value.title()} skill increased to level " + f"{new_level}" + ), ) ) @@ -486,7 +486,4 @@ def calculate_action_success_chance_with_agent( def agent_roster_summary(self, state: GameState) -> List[Dict[str, object]]: """Return a list of agent progression summaries for display.""" - return [ - agent_prog.summary() - for agent_prog in state.agent_progression.values() - ] + return [agent_prog.summary() for agent_prog in state.agent_progression.values()] diff --git a/tests/ai_player/test_actor.py b/tests/ai_player/test_actor.py index bb7b612d..dcdc996a 100644 --- a/tests/ai_player/test_actor.py +++ b/tests/ai_player/test_actor.py @@ -485,6 +485,7 @@ def test_diplomatic_100_tick_run(self) -> None: def test_deterministic_100_tick_outcome(self) -> None: """100-tick run should be deterministic with fixed seed.""" + def run_session() -> float: engine = SimEngine() engine.initialize_state(world="default") diff --git a/tests/ai_player/test_observer.py b/tests/ai_player/test_observer.py index 16e453f8..f11c3621 100644 --- a/tests/ai_player/test_observer.py +++ b/tests/ai_player/test_observer.py @@ -249,10 +249,9 @@ def test_observer_detects_stability_decline(self) -> None: assert report.stability_trend.end_value <= 1.0 assert isinstance(report.stability_trend.delta, float) - assert any( - "stability" in c.lower() - for c in report.commentary - ), "Commentary should mention stability" + assert any("stability" in c.lower() for c in report.commentary), ( + "Commentary should mention stability" + ) def test_observer_tracks_faction_legitimacy(self) -> None: """Observer should track faction legitimacy changes.""" @@ -341,8 +340,7 @@ def test_commentary_stability_declining_significantly(self) -> None: comments = observer._generate_commentary(stability_trend, {}, []) assert any( - "[STABILITY]" in c and "Declined significantly" in c - for c in comments + "[STABILITY]" in c and "Declined significantly" in c for c in comments ) assert any("0.90" in c and "0.60" in c for c in comments) diff --git a/tests/echoes/conftest.py b/tests/echoes/conftest.py index b5cfcd8b..30123245 100644 --- a/tests/echoes/conftest.py +++ b/tests/echoes/conftest.py @@ -1,8 +1,9 @@ """Pytest configuration for echoes tests.""" import pytest -from gengine.echoes.settings import load_simulation_config + from gengine.echoes.gateway.app import GatewaySettings +from gengine.echoes.settings import load_simulation_config @pytest.fixture @@ -21,4 +22,3 @@ def sim_config(): def gateway_settings(): """Return default gateway settings.""" return GatewaySettings(service_url="local") - diff --git a/tests/echoes/test_agent_system.py b/tests/echoes/test_agent_system.py index 2066868c..63d7ed5d 100644 --- a/tests/echoes/test_agent_system.py +++ b/tests/echoes/test_agent_system.py @@ -19,7 +19,9 @@ def test_agent_system_produces_deterministic_intents() -> None: rng = random.Random(42) intents_second = system.tick(state, rng=rng) - assert [intent.intent for intent in intents_first] == [intent.intent for intent in intents_second] + assert [intent.intent for intent in intents_first] == [ + intent.intent for intent in intents_second + ] assert intents_first @@ -30,7 +32,9 @@ def test_sim_engine_emits_agent_actions() -> None: reports = engine.advance_ticks(1) assert reports[0].agent_actions - assert any("inspects" in event or "negotiates" in event for event in reports[0].events) + assert any( + "inspects" in event or "negotiates" in event for event in reports[0].events + ) def test_agent_scoring_logic_traits() -> None: @@ -73,7 +77,10 @@ def test_agent_scoring_logic_traits() -> None: score_map_low = dict(scores_low) assert score_map["STABILIZE_UNREST"] > score_map_low["STABILIZE_UNREST"] - assert abs(score_map["STABILIZE_UNREST"] - score_map_low["STABILIZE_UNREST"] - 0.3) < 0.0001 + assert ( + abs(score_map["STABILIZE_UNREST"] - score_map_low["STABILIZE_UNREST"] - 0.3) + < 0.0001 + ) def test_agent_scoring_logic_environment() -> None: @@ -97,7 +104,12 @@ def test_agent_scoring_logic_environment() -> None: assert score_map_high["STABILIZE_UNREST"] > score_map_low["STABILIZE_UNREST"] # Difference should be exactly 1.0 (the difference in unrest) - assert abs(score_map_high["STABILIZE_UNREST"] - score_map_low["STABILIZE_UNREST"] - 1.0) < 0.0001 + assert ( + abs( + score_map_high["STABILIZE_UNREST"] - score_map_low["STABILIZE_UNREST"] - 1.0 + ) + < 0.0001 + ) def test_agent_decision_edge_cases() -> None: @@ -128,16 +140,21 @@ def test_agent_decision_edge_cases() -> None: def test_agent_decision_no_options() -> None: - """Verify behavior when no options are available (simulated by mocking _calculate_scores).""" + """Verify behavior when no options are available. + + Simulated by mocking _calculate_scores. + """ state = load_world_bundle() system = AgentSystem() rng = random.Random(42) - agent = Agent(id="test", name="Test", role="Tester", home_district=None, faction_id=None) + agent = Agent( + id="test", name="Test", role="Tester", home_district=None, faction_id=None + ) # Mock _calculate_scores to return empty list # We can just assign a lambda to the instance method system._calculate_scores = lambda *args: [] # type: ignore intent = system._decide(agent, {}, {}, state, rng) - assert intent is None \ No newline at end of file + assert intent is None diff --git a/tests/echoes/test_anthropic_provider.py b/tests/echoes/test_anthropic_provider.py index 53bdace4..c2634791 100644 --- a/tests/echoes/test_anthropic_provider.py +++ b/tests/echoes/test_anthropic_provider.py @@ -1,8 +1,9 @@ """Integration tests for Anthropic provider with mocked API responses.""" -import pytest from unittest.mock import MagicMock, patch +import pytest + from gengine.echoes.llm.anthropic_provider import AnthropicProvider from gengine.echoes.llm.settings import LLMSettings @@ -32,7 +33,7 @@ async def test_parse_intent_inspect(self, provider: AnthropicProvider) -> None: # Mock Anthropic API response with JSON mock_response = MagicMock() mock_response.content = [MagicMock()] - mock_response.content[0].text = '''{ + mock_response.content[0].text = """{ "intent_type": "INSPECT", "confidence": 0.95, "parameters": { @@ -41,7 +42,7 @@ async def test_parse_intent_inspect(self, provider: AnthropicProvider) -> None: "focus_areas": ["security", "morale"] }, "narrative_context": "Checking security and morale in Perimeter Hollow" - }''' + }""" mock_response.model_dump_json.return_value = '{"mock": "response"}' with patch.object( @@ -64,11 +65,13 @@ async def test_parse_intent_inspect(self, provider: AnthropicProvider) -> None: assert result.confidence == 0.95 @pytest.mark.anyio - async def test_parse_intent_deploy_resource(self, provider: AnthropicProvider) -> None: + async def test_parse_intent_deploy_resource( + self, provider: AnthropicProvider + ) -> None: """Test parsing a deploy resource intent.""" mock_response = MagicMock() mock_response.content = [MagicMock()] - mock_response.content[0].text = '''{ + mock_response.content[0].text = """{ "intent_type": "DEPLOY_RESOURCE", "confidence": 0.9, "parameters": { @@ -77,7 +80,7 @@ async def test_parse_intent_deploy_resource(self, provider: AnthropicProvider) - "target_district": "spire", "purpose": "power infrastructure" } - }''' + }""" mock_response.model_dump_json.return_value = '{"mock": "response"}' with patch.object( @@ -234,14 +237,16 @@ async def test_narrate_api_error(self, provider: AnthropicProvider) -> None: assert "Overloaded" in result.raw_response @pytest.mark.anyio - async def test_parse_intent_missing_intent_type(self, provider: AnthropicProvider) -> None: + async def test_parse_intent_missing_intent_type( + self, provider: AnthropicProvider + ) -> None: """Test handling response with missing intent_type.""" mock_response = MagicMock() mock_response.content = [MagicMock()] - mock_response.content[0].text = '''{ + mock_response.content[0].text = """{ "confidence": 0.5, "parameters": {"some": "data"} - }''' + }""" mock_response.model_dump_json.return_value = '{"mock": "response"}' with patch.object( diff --git a/tests/echoes/test_campaign.py b/tests/echoes/test_campaign.py index ab53617c..48d667f7 100644 --- a/tests/echoes/test_campaign.py +++ b/tests/echoes/test_campaign.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import tempfile from datetime import datetime, timezone from pathlib import Path @@ -247,8 +246,8 @@ def temp_dir(self): def test_campaign_workflow(self, temp_dir): """Test complete campaign workflow.""" - from gengine.echoes.sim import SimEngine from gengine.echoes.cli.shell import LocalBackend + from gengine.echoes.sim import SimEngine settings = CampaignSettings( campaigns_dir=temp_dir, @@ -287,8 +286,8 @@ def test_campaign_workflow(self, temp_dir): def test_campaign_autosave_on_advance(self, temp_dir): """Test that autosave happens when advancing ticks.""" - from gengine.echoes.sim import SimEngine from gengine.echoes.cli.shell import LocalBackend + from gengine.echoes.sim import SimEngine settings = CampaignSettings( campaigns_dir=temp_dir, diff --git a/tests/echoes/test_cli_shell.py b/tests/echoes/test_cli_shell.py index 31525171..ca29fbf7 100644 --- a/tests/echoes/test_cli_shell.py +++ b/tests/echoes/test_cli_shell.py @@ -5,20 +5,23 @@ import json from pathlib import Path -from fastapi.testclient import TestClient +import httpx import pytest +from fastapi.testclient import TestClient from gengine.echoes.cli import run_commands from gengine.echoes.cli.shell import ( + PROMPT, EchoesShell, LocalBackend, ServiceBackend, - _render_focus_state, + _get_prompt, _render_director_feed, + _render_focus_state, _render_history, _render_summary, - _get_prompt, - PROMPT, +) +from gengine.echoes.cli.shell import ( main as cli_main, ) from gengine.echoes.client import SimServiceClient @@ -119,7 +122,7 @@ def test_service_backend_close_releases_client() -> None: backend = ServiceBackend(client) backend.close() # Subsequent operations should fail if client is closed - with pytest.raises(Exception): + with pytest.raises((RuntimeError, httpx.HTTPError)): backend.summary() @@ -129,7 +132,7 @@ def test_shell_save_with_no_path() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("save") assert "Usage: save" in result.output @@ -140,10 +143,10 @@ def test_shell_load_with_invalid_syntax() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("load") assert "Usage: load" in result.output - + result = shell.execute("load invalid") assert "Usage: load" in result.output @@ -154,7 +157,7 @@ def test_shell_run_with_invalid_count() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("run notanumber") assert "Usage: run" in result.output @@ -165,7 +168,7 @@ def test_shell_history_with_invalid_count() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("history notanumber") assert "Usage: history" in result.output @@ -176,7 +179,7 @@ def test_shell_director_with_invalid_count() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("director notanumber") assert "Usage: director" in result.output @@ -187,7 +190,7 @@ def test_shell_postmortem_with_args() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("postmortem extra") assert "Usage: postmortem" in result.output @@ -198,7 +201,7 @@ def test_shell_unknown_command() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("nosuchcommand") assert "Unknown command" in result.output assert "nosuchcommand" in result.output @@ -210,7 +213,7 @@ def test_shell_quit_is_alias_for_exit() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("quit") assert result.should_exit is True assert "Exiting" in result.output @@ -222,7 +225,7 @@ def test_shell_next_with_args_rejected() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("next 5") assert "Usage: next" in result.output @@ -233,7 +236,7 @@ def test_shell_run_without_count_rejected() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("run") assert "Usage: run" in result.output @@ -344,7 +347,9 @@ def close(self) -> None: monkeypatch.setattr("gengine.echoes.cli.shell.SimServiceClient", FakeClient) - exit_code = cli_main(["--service-url", "http://fake", "--script", "summary;run 1;exit"]) + exit_code = cli_main( + ["--service-url", "http://fake", "--script", "summary;run 1;exit"] + ) assert exit_code == 0 captured = capsys.readouterr() @@ -408,7 +413,9 @@ def test_run_commands_respects_script_cap() -> None: ) config = SimulationConfig(limits=limits) - outputs = run_commands(["summary", "summary", "summary"], engine=engine, config=config) + outputs = run_commands( + ["summary", "summary", "summary"], engine=engine, config=config + ) assert outputs[-1].startswith("Safeguard: script exceeded 2 commands") @@ -420,7 +427,7 @@ def fake_input(_: str) -> str: try: return next(inputs) except StopIteration: # pragma: no cover - defensive - raise EOFError + raise EOFError from None monkeypatch.setattr("builtins.input", fake_input) @@ -859,7 +866,6 @@ def test_render_director_feed_includes_story_seed_matches() -> None: analysis = { "story_seeds": [ { - "seed_id": "hollow-supply-chain", "title": "Smuggling Lanes Exposed", "district_id": "perimeter-hollow", @@ -927,7 +933,7 @@ def test_service_backend_focus_state() -> None: client = SimServiceClient("http://test") client._client = client_test backend = ServiceBackend(client) - + focus_data = backend.focus_state() assert isinstance(focus_data, dict) # Should have district_id or be empty dict @@ -943,7 +949,7 @@ def test_service_backend_set_focus() -> None: client = SimServiceClient("http://test") client._client = client_test backend = ServiceBackend(client) - + focus_data = backend.set_focus("industrial-tier") assert isinstance(focus_data, dict) # After setting focus, should have district_id @@ -959,7 +965,7 @@ def test_service_backend_focus_history() -> None: client = SimServiceClient("http://test") client._client = client_test backend = ServiceBackend(client) - + history = backend.focus_history() assert isinstance(history, list) @@ -973,7 +979,7 @@ def test_service_backend_post_mortem() -> None: client = SimServiceClient("http://test") client._client = client_test backend = ServiceBackend(client) - + data = backend.post_mortem() assert isinstance(data, dict) @@ -984,7 +990,7 @@ def test_shell_focus_command() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("focus") assert result.output assert "focus" in result.output.lower() or "Center:" in result.output @@ -996,7 +1002,7 @@ def test_shell_focus_set_command() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("focus industrial-tier") assert result.output assert "industrial-tier" in result.output @@ -1008,7 +1014,7 @@ def test_shell_focus_clear_command() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + # Set focus first shell.execute("focus industrial-tier") # Then clear it @@ -1022,7 +1028,7 @@ def test_shell_history_with_limit() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + # Run some ticks to generate history shell.execute("run 5") result = shell.execute("history 2") @@ -1035,7 +1041,7 @@ def test_shell_director_with_limit() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + # Run some ticks to generate director feed shell.execute("run 5") result = shell.execute("director 2") @@ -1048,7 +1054,7 @@ def test_shell_empty_command() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + result = shell.execute("") assert result.output == "" @@ -1059,7 +1065,7 @@ def test_shell_history_with_no_data() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + # Don't run any ticks, so no history result = shell.execute("history") assert result.output == "No focus history recorded." @@ -1071,7 +1077,7 @@ def test_shell_director_with_no_data() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend) - + # Don't run any ticks, so no director feed result = shell.execute("director") assert result.output == "No director feed recorded." @@ -1087,7 +1093,7 @@ def test_shell_load_snapshot_with_service_backend() -> None: client._client = client_test backend = ServiceBackend(client) shell = EchoesShell(backend) - + result = shell.execute("load snapshot /tmp/fake.json") assert "Loading snapshots requires local backend" in result.output @@ -1109,7 +1115,7 @@ def test_render_summary_with_pollution_extremes() -> None: }, }, } - + rendered = _render_summary(summary) assert "avg pollution" in rendered assert "extremes" in rendered @@ -1133,7 +1139,7 @@ def test_render_summary_with_diffusion_samples() -> None: ], }, } - + rendered = _render_summary(summary) assert "diffusion samples" in rendered assert "district-a" in rendered @@ -1159,20 +1165,21 @@ def test_render_summary_with_spatial_weights() -> None: "spatial_metrics": {"distance_reference": 1.5}, }, } - + rendered = _render_summary(summary) assert "coords" in rendered assert "adjacent" in rendered assert "spatial weights" in rendered assert "distance ref" in rendered + def test_shell_with_rich_formatting_enabled() -> None: """Verify that shell can use rich formatting when enabled.""" engine = SimEngine() engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend, enable_rich=True) - + result = shell.execute("summary") assert result.output # Rich formatting should add visual elements @@ -1187,7 +1194,7 @@ def test_shell_rich_director_command() -> None: engine.advance_ticks(10) backend = LocalBackend(engine) shell = EchoesShell(backend, enable_rich=True) - + result = shell.execute("director") assert result.output @@ -1198,7 +1205,7 @@ def test_shell_rich_map_command() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) shell = EchoesShell(backend, enable_rich=True) - + result = shell.execute("map") assert result.output # Rich map should have visual elements @@ -1207,6 +1214,7 @@ def test_shell_rich_map_command() -> None: # Tests for M7.2 Explanations CLI commands + def test_timeline_command() -> None: """Test the timeline CLI command.""" engine = SimEngine() diff --git a/tests/echoes/test_content_loader.py b/tests/echoes/test_content_loader.py index 141c7ff6..0c6f5320 100644 --- a/tests/echoes/test_content_loader.py +++ b/tests/echoes/test_content_loader.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from pathlib import Path import pytest @@ -128,7 +127,9 @@ def test_geometry_enrichment_derives_adjacency(tmp_path: Path) -> None: state = load_world_bundle("geom", content_root=tmp_path) - adjacency = {district.id: set(district.adjacent) for district in state.city.districts} + adjacency = { + district.id: set(district.adjacent) for district in state.city.districts + } assert adjacency["alpha"] == {"beta", "gamma"} assert adjacency["beta"] == {"alpha", "gamma"} assert adjacency["gamma"] == {"alpha", "beta"} @@ -162,7 +163,9 @@ def test_geometry_enrichment_derives_adjacency(tmp_path: Path) -> None: ), ], ) -def test_story_seed_loader_validates_entity_references(tmp_path: Path, overrides: dict, message: str) -> None: +def test_story_seed_loader_validates_entity_references( + tmp_path: Path, overrides: dict, message: str +) -> None: world_root = _write_story_seed_world(tmp_path, world_name="seed-refs") _write_story_seeds_file(world_root, [_story_seed_payload(**overrides)]) diff --git a/tests/echoes/test_director_bridge.py b/tests/echoes/test_director_bridge.py index c48883e3..99f44677 100644 --- a/tests/echoes/test_director_bridge.py +++ b/tests/echoes/test_director_bridge.py @@ -68,8 +68,7 @@ def test_director_bridge_record_trims_history_and_clones_payloads() -> None: assert len(second_snapshot["spatial_weights"]) == 1 assert second_snapshot["allocation"]["list_field"] == [1, 2, 3] assert ( - second_snapshot["allocation"]["list_field"] - is not allocation["list_field"] + second_snapshot["allocation"]["list_field"] is not allocation["list_field"] ), "allocation list must be cloned" assert state.metadata["director_history"][-1]["tick"] == 6 assert len(state.metadata["director_history"]) == 1 diff --git a/tests/echoes/test_display.py b/tests/echoes/test_display.py index 653cd749..69f0ebaa 100644 --- a/tests/echoes/test_display.py +++ b/tests/echoes/test_display.py @@ -15,9 +15,9 @@ def test_render_summary_table_basic() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) summary = backend.summary() - + output = display.render_summary_table(summary) - + assert "World Status" in output assert "Stability" in output assert "Environment Impact" in output or len(output) > 0 @@ -28,15 +28,15 @@ def test_render_summary_table_with_story_seeds() -> None: config = load_simulation_config() engine = SimEngine(config=config) engine.initialize_state(world="default") - + # Advance ticks to potentially trigger story seeds engine.advance_ticks(50) backend = LocalBackend(engine) summary = backend.summary() - + output = display.render_summary_table(summary) assert output # Should produce some output - + # If story seeds are active, they should appear seeds = summary.get("story_seeds") or [] if seeds: @@ -51,11 +51,11 @@ def test_render_director_table() -> None: engine.advance_ticks(10) backend = LocalBackend(engine) summary = backend.summary() - + feed = summary.get("director_feed") or {} history = summary.get("director_history") or [] analysis = summary.get("director_analysis") - + if feed: output = display.render_director_table(feed, history, analysis) assert output @@ -69,9 +69,9 @@ def test_render_map_overlay() -> None: engine.initialize_state(world="default") backend = LocalBackend(engine) summary = backend.summary() - + output = display.render_map_overlay(summary) - + assert output assert "City Map" in output or "District" in output @@ -84,9 +84,9 @@ def test_render_with_profiling_data() -> None: engine.advance_ticks(5) backend = LocalBackend(engine) summary = backend.summary() - + output = display.render_summary_table(summary) - + # Profiling should be present profiling = summary.get("profiling") if profiling: @@ -108,8 +108,8 @@ def test_environment_panel_colors() -> None: "biodiversity": {"value": 0.4, "delta": -0.05}, }, } - + output = display.render_summary_table(summary) - + assert output assert "scarcity" in output.lower() or "pressure" in output.lower() diff --git a/tests/echoes/test_environment_system.py b/tests/echoes/test_environment_system.py index 198fa810..a85bdc33 100644 --- a/tests/echoes/test_environment_system.py +++ b/tests/echoes/test_environment_system.py @@ -106,7 +106,9 @@ def test_environment_system_reacts_to_faction_actions() -> None: ) assert impact.faction_effects - assert any(effect["action"] == "SABOTAGE_RIVAL" for effect in impact.faction_effects) + assert any( + effect["action"] == "SABOTAGE_RIVAL" for effect in impact.faction_effects + ) assert any("pollution" in event for event in impact.events) @@ -168,10 +170,13 @@ def test_environment_diffusion_reports_extremes_and_samples() -> None: assert impact.diffusion_samples assert impact.extremes assert impact.average_pollution == pytest.approx( - sum(d.modifiers.pollution for d in state.city.districts) / len(state.city.districts) + sum(d.modifiers.pollution for d in state.city.districts) + / len(state.city.districts) ) assert all(abs(sample["delta"]) <= 0.01 for sample in impact.diffusion_samples) - assert any(sample.get("neighbor_avg") is not None for sample in impact.diffusion_samples) + assert any( + sample.get("neighbor_avg") is not None for sample in impact.diffusion_samples + ) def test_biodiversity_stability_feedback() -> None: @@ -191,7 +196,11 @@ def test_biodiversity_stability_feedback() -> None: ) ) - impact = system.tick(state, rng=random.Random(3), economy_report=EconomyReport(prices={}, shortages={})) + impact = system.tick( + state, + rng=random.Random(3), + economy_report=EconomyReport(prices={}, shortages={}), + ) assert state.environment.stability < 0.7 assert impact.stability_effects["biodiversity_delta"] < 0 diff --git a/tests/echoes/test_explanations.py b/tests/echoes/test_explanations.py index 898bfc21..27cee588 100644 --- a/tests/echoes/test_explanations.py +++ b/tests/echoes/test_explanations.py @@ -204,7 +204,9 @@ def test_record_tick_captures_faction_deltas(self, sample_state: GameState): faction_deltas={"union-of-flux": 0.05, "cartel-of-mist": -0.03}, ) # Should have events for faction legitimacy changes - faction_events = [e for e in entry.events if e.category == CausalCategory.FACTION] + faction_events = [ + e for e in entry.events if e.category == CausalCategory.FACTION + ] assert len(faction_events) >= 2 def test_record_tick_captures_agent_actions(self, sample_state: GameState): @@ -246,7 +248,9 @@ def test_record_tick_captures_faction_actions(self, sample_state: GameState): tick=11, faction_actions=faction_actions, ) - faction_events = [e for e in entry.events if e.category == CausalCategory.FACTION] + faction_events = [ + e for e in entry.events if e.category == CausalCategory.FACTION + ] assert len(faction_events) >= 1 # Should have effects for investment invest_event = next( diff --git a/tests/echoes/test_faction_system.py b/tests/echoes/test_faction_system.py index e9de4afc..690f0969 100644 --- a/tests/echoes/test_faction_system.py +++ b/tests/echoes/test_faction_system.py @@ -59,7 +59,9 @@ def test_faction_system_can_sabotage_rivals() -> None: sabotage = None for _ in range(5): actions = system.tick(state, rng=rng) - sabotage = next((action for action in actions if action.action == "SABOTAGE_RIVAL"), None) + sabotage = next( + (action for action in actions if action.action == "SABOTAGE_RIVAL"), None + ) if sabotage is not None: break assert sabotage is not None @@ -79,7 +81,9 @@ def test_faction_system_invests_to_calm_unrest() -> None: actions = system.tick(state, rng=random.Random(2)) - invest = next((action for action in actions if action.action == "INVEST_DISTRICT"), None) + invest = next( + (action for action in actions if action.action == "INVEST_DISTRICT"), None + ) assert invest is not None target = next(d for d in state.city.districts if d.id == invest.target) assert target.modifiers.unrest < 0.9 @@ -97,7 +101,9 @@ def test_faction_system_recruits_when_resources_low() -> None: actions = system.tick(state, rng=random.Random(3)) - recruit = next((action for action in actions if action.action == "RECRUIT_SUPPORT"), None) + recruit = next( + (action for action in actions if action.action == "RECRUIT_SUPPORT"), None + ) assert recruit is not None assert recruit.resource_delta > 0 @@ -113,4 +119,4 @@ def test_faction_system_takes_no_action_when_stable() -> None: actions = system.tick(state, rng=random.Random(4)) - assert actions == [] \ No newline at end of file + assert actions == [] diff --git a/tests/echoes/test_gateway_client.py b/tests/echoes/test_gateway_client.py index 071df116..e4db7eec 100644 --- a/tests/echoes/test_gateway_client.py +++ b/tests/echoes/test_gateway_client.py @@ -44,7 +44,9 @@ def test_render_response_with_error(capsys) -> None: def test_main_with_script() -> None: """Verify that main() parses --script and delegates to _run_session.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session: + with patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session: result = main(["--script", "summary;exit"]) assert result == 0 mock_session.assert_called_once_with("ws://localhost:8100/ws", ["summary", "exit"]) @@ -52,15 +54,20 @@ def test_main_with_script() -> None: def test_main_uses_default_gateway_url() -> None: """Verify that main() uses the default gateway URL when no env is set.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session: + with patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session: main(["--script", "exit"]) mock_session.assert_called_once_with("ws://localhost:8100/ws", ["exit"]) def test_main_uses_env_gateway_url() -> None: """Verify that main() respects the ECHOES_GATEWAY_URL environment variable.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session, patch.dict( - "os.environ", {"ECHOES_GATEWAY_URL": "ws://custom:9000/ws"} + with ( + patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session, + patch.dict("os.environ", {"ECHOES_GATEWAY_URL": "ws://custom:9000/ws"}), ): main(["--script", "exit"]) mock_session.assert_called_once_with("ws://custom:9000/ws", ["exit"]) @@ -86,27 +93,37 @@ def test_render_response_with_empty_dict(capsys) -> None: def test_main_with_gateway_url_flag() -> None: """Verify that --gateway-url flag overrides the default URL.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session: + with patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session: main(["--gateway-url", "ws://custom:9000/ws", "--script", "exit"]) mock_session.assert_called_once_with("ws://custom:9000/ws", ["exit"]) def test_main_splits_script_commands() -> None: """Verify that --script semicolon-separated commands are parsed correctly.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session: + with patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session: main(["--script", "summary; next; exit"]) - mock_session.assert_called_once_with("ws://localhost:8100/ws", ["summary", "next", "exit"]) + mock_session.assert_called_once_with( + "ws://localhost:8100/ws", ["summary", "next", "exit"] + ) def test_main_empty_script_strips_whitespace() -> None: """Verify that empty/whitespace-only commands are filtered out.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session: + with patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session: main(["--script", "; ; exit; ;"]) mock_session.assert_called_once_with("ws://localhost:8100/ws", ["exit"]) def test_main_no_script_runs_interactively() -> None: """Verify that main() without --script invokes asyncio.run.""" - with patch("gengine.echoes.gateway.client._run_session", new_callable=AsyncMock) as mock_session: + with patch( + "gengine.echoes.gateway.client._run_session", new_callable=AsyncMock + ) as mock_session: main([]) mock_session.assert_called_once_with("ws://localhost:8100/ws", []) diff --git a/tests/echoes/test_gateway_intent_mapper.py b/tests/echoes/test_gateway_intent_mapper.py index 75ce06e4..502f7732 100644 --- a/tests/echoes/test_gateway_intent_mapper.py +++ b/tests/echoes/test_gateway_intent_mapper.py @@ -230,10 +230,10 @@ def test_map_request_report_invalid_count(self): def test_map_unknown_intent_type(self): """Test mapping unknown intent type raises error.""" mapper = IntentMapper() - + # Create a mock intent that's not a recognized type class UnknownIntent: pass - + with pytest.raises(ValueError, match="Unknown intent type"): mapper.map_intent_to_command(UnknownIntent()) diff --git a/tests/echoes/test_gateway_llm_client.py b/tests/echoes/test_gateway_llm_client.py index 3afeacd1..4792db96 100644 --- a/tests/echoes/test_gateway_llm_client.py +++ b/tests/echoes/test_gateway_llm_client.py @@ -1,11 +1,11 @@ """Tests for gateway LLM client.""" -import pytest -import httpx from unittest.mock import Mock, patch +import httpx + from gengine.echoes.gateway.llm_client import LLMClient -from gengine.echoes.llm import InspectIntent, IntentType +from gengine.echoes.llm import InspectIntent class TestLLMClient: diff --git a/tests/echoes/test_gateway_service.py b/tests/echoes/test_gateway_service.py index 262987ff..50bcbfc6 100644 --- a/tests/echoes/test_gateway_service.py +++ b/tests/echoes/test_gateway_service.py @@ -4,8 +4,8 @@ from fastapi.testclient import TestClient -from gengine.echoes.gateway.app import GatewaySettings, create_gateway_app from gengine.echoes.cli.shell import LocalBackend +from gengine.echoes.gateway.app import GatewaySettings, create_gateway_app from gengine.echoes.sim import SimEngine @@ -107,17 +107,17 @@ def test_gateway_executes_multiple_commands(sim_config, gateway_settings) -> Non client = TestClient(app) with client.websocket_connect("/ws") as websocket: _ = websocket.receive_json() - + websocket.send_json({"command": "summary"}) result1 = websocket.receive_json() assert result1["type"] == "result" assert "Current world summary" in result1["output"] - + websocket.send_json({"command": "next"}) result2 = websocket.receive_json() assert result2["type"] == "result" assert "Tick" in result2["output"] - + websocket.send_json({"command": "exit"}) final = websocket.receive_json() assert final["should_exit"] is True @@ -145,6 +145,7 @@ def test_gateway_handles_empty_string_command(sim_config, gateway_settings) -> N def test_gateway_settings_from_env() -> None: """Verify that GatewaySettings.from_env() reads environment variables.""" import os + old_env = { "ECHOES_GATEWAY_SERVICE_URL": os.environ.get("ECHOES_GATEWAY_SERVICE_URL"), "ECHOES_GATEWAY_HOST": os.environ.get("ECHOES_GATEWAY_HOST"), @@ -154,7 +155,7 @@ def test_gateway_settings_from_env() -> None: os.environ["ECHOES_GATEWAY_SERVICE_URL"] = "http://test:9000" os.environ["ECHOES_GATEWAY_HOST"] = "127.0.0.1" os.environ["ECHOES_GATEWAY_PORT"] = "9100" - + settings = GatewaySettings.from_env() assert settings.service_url == "http://test:9000" assert settings.host == "127.0.0.1" @@ -168,7 +169,10 @@ def test_gateway_settings_from_env() -> None: def test_gateway_app_uses_service_backend_factory_by_default(sim_config) -> None: - """Verify that create_gateway_app creates service backend factory when none provided.""" + """Verify that create_gateway_app creates service backend factory. + + Tests that the default factory is used when none is provided. + """ settings = GatewaySettings(service_url="http://localhost:8000") # Call without backend_factory to trigger the default factory creation app = create_gateway_app(config=sim_config, settings=settings) @@ -188,7 +192,7 @@ def test_gateway_handles_invalid_json_bytes(sim_config, gateway_settings) -> Non with client.websocket_connect("/ws") as websocket: _ = websocket.receive_json() # Send invalid JSON as bytes - websocket.send_bytes(b'not valid json') + websocket.send_bytes(b"not valid json") response = websocket.receive_json() assert response["type"] == "result" websocket.send_text("exit") diff --git a/tests/echoes/test_llm_prompts.py b/tests/echoes/test_llm_prompts.py index 4a1b8378..122f7af6 100644 --- a/tests/echoes/test_llm_prompts.py +++ b/tests/echoes/test_llm_prompts.py @@ -42,9 +42,7 @@ def test_inspect_function_schema(self): def test_negotiate_function_schema(self): """Test negotiate function has correct schema.""" negotiate_func = next( - f - for f in OPENAI_INTENT_FUNCTIONS - if f["name"] == "negotiate_with_faction" + f for f in OPENAI_INTENT_FUNCTIONS if f["name"] == "negotiate_with_faction" ) assert "targets" in negotiate_func["parameters"]["properties"] assert "levers" in negotiate_func["parameters"]["properties"] @@ -70,7 +68,14 @@ def test_request_report_function_schema(self): props = report_func["parameters"]["properties"] assert "report_type" in props # Check report type enum - expected_types = ["summary", "district", "faction", "agent", "environment", "director"] + expected_types = [ + "summary", + "district", + "faction", + "agent", + "environment", + "director", + ] assert props["report_type"]["enum"] == expected_types @@ -163,9 +168,7 @@ def test_prompt_with_context(self): "tick": 42, "recent_events": ["Pollution increased", "Agent recruited"], } - prompt = build_intent_parsing_prompt( - "stabilize the district", context=context - ) + prompt = build_intent_parsing_prompt("stabilize the district", context=context) assert "Current district: industrial-tier" in prompt assert "Current tick: 42" in prompt assert "Recent events:" in prompt diff --git a/tests/echoes/test_llm_providers.py b/tests/echoes/test_llm_providers.py index 8e4608b1..8494808e 100644 --- a/tests/echoes/test_llm_providers.py +++ b/tests/echoes/test_llm_providers.py @@ -6,7 +6,6 @@ from gengine.echoes.llm.providers import ( IntentParseResult, - LLMProvider, NarrateResult, StubProvider, create_provider, diff --git a/tests/echoes/test_openai_provider.py b/tests/echoes/test_openai_provider.py index c10d30f8..bff2c8cd 100644 --- a/tests/echoes/test_openai_provider.py +++ b/tests/echoes/test_openai_provider.py @@ -1,9 +1,9 @@ """Integration tests for OpenAI provider with mocked API responses.""" -import pytest from unittest.mock import AsyncMock, MagicMock, patch -from gengine.echoes.llm import InspectIntent, NegotiateIntent +import pytest + from gengine.echoes.llm.openai_provider import OpenAIProvider from gengine.echoes.llm.settings import LLMSettings @@ -93,7 +93,9 @@ async def test_parse_intent_negotiate(self, provider: OpenAIProvider) -> None: assert intent["goal"] == "reduce protests" @pytest.mark.anyio - async def test_parse_intent_no_function_call(self, provider: OpenAIProvider) -> None: + async def test_parse_intent_no_function_call( + self, provider: OpenAIProvider + ) -> None: """Test handling response without function call.""" mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -174,9 +176,7 @@ async def test_narrate_no_events(self, provider: OpenAIProvider) -> None: """Test narrating with no events.""" mock_response = MagicMock() mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = ( - "The city remains quiet for now." - ) + mock_response.choices[0].message.content = "The city remains quiet for now." mock_response.usage = MagicMock() mock_response.usage.total_tokens = 10 mock_response.model_dump_json.return_value = '{"mock": "response"}' diff --git a/tests/echoes/test_post_mortem.py b/tests/echoes/test_post_mortem.py index f8fd5b27..270d594d 100644 --- a/tests/echoes/test_post_mortem.py +++ b/tests/echoes/test_post_mortem.py @@ -29,11 +29,25 @@ def test_generate_post_mortem_summary_builds_recap() -> None: } ] state.metadata["story_seed_lifecycle"] = { - "energy-quota-crisis": {"state": "archived", "entered_tick": 150, "cooldown_remaining": 2} + "energy-quota-crisis": { + "state": "archived", + "entered_tick": 150, + "cooldown_remaining": 2, + } } state.metadata["story_seed_lifecycle_history"] = [ - {"seed_id": "energy-quota-crisis", "from": "active", "to": "archived", "tick": 150}, - {"seed_id": "hollow-supply-chain", "from": "primed", "to": "active", "tick": 160}, + { + "seed_id": "energy-quota-crisis", + "from": "active", + "to": "archived", + "tick": 150, + }, + { + "seed_id": "hollow-supply-chain", + "from": "primed", + "to": "active", + "tick": 160, + }, ] summary = generate_post_mortem_summary(state) diff --git a/tests/echoes/test_progression.py b/tests/echoes/test_progression.py index c74ffa62..392f3feb 100644 --- a/tests/echoes/test_progression.py +++ b/tests/echoes/test_progression.py @@ -2,9 +2,6 @@ from __future__ import annotations -import random -from typing import Dict, List - import pytest from gengine.echoes.content import load_world_bundle @@ -19,7 +16,6 @@ ) from gengine.echoes.sim import SimEngine from gengine.echoes.systems.progression import ( - ProgressionEvent, ProgressionSettings, ProgressionSystem, ) @@ -265,7 +261,7 @@ def test_tick_with_agent_actions(self) -> None: {"intent": "NEGOTIATE_FACTION", "agent_id": "agent-2"}, ] - events = system.tick(state, agent_actions=agent_actions) + system.tick(state, agent_actions=agent_actions) # Progression should be initialized assert state.progression is not None @@ -280,7 +276,7 @@ def test_tick_with_faction_actions(self) -> None: {"faction_id": "cartel-of-mist", "action": "INVEST_DISTRICT"}, ] - events = system.tick(state, faction_actions=faction_actions) + system.tick(state, faction_actions=faction_actions) assert state.progression is not None # Should have reputation changes @@ -292,13 +288,15 @@ def test_tick_records_history(self) -> None: system = ProgressionSystem() # Grant enough experience to level up - state.ensure_progression().skills[SkillDomain.INVESTIGATION.value].experience = 9.0 + state.ensure_progression().skills[ + SkillDomain.INVESTIGATION.value + ].experience = 9.0 agent_actions = [ {"intent": "INSPECT_DISTRICT", "agent_id": "agent-1"}, ] - events = system.tick(state, agent_actions=agent_actions) + system.tick(state, agent_actions=agent_actions) # Should have recorded the level up event history = state.metadata.get("progression_history", []) @@ -310,15 +308,13 @@ def test_calculate_action_success_chance(self) -> None: progression = state.ensure_progression() # Default chance should be around 0.75 - chance = system.calculate_action_success_chance( - progression, "INSPECT_DISTRICT" - ) + chance = system.calculate_action_success_chance(progression, "INSPECT_DISTRICT") assert 0.5 <= chance <= 1.0 def test_sabotage_affects_both_factions(self) -> None: state = load_world_bundle() system = ProgressionSystem() - + faction_actions = [ { "faction_id": "cartel-of-mist", @@ -327,7 +323,7 @@ def test_sabotage_affects_both_factions(self) -> None: }, ] - events = system.tick(state, faction_actions=faction_actions) + system.tick(state, faction_actions=faction_actions) # Both factions should have reputation changes assert "union-of-flux" in state.progression.reputation @@ -395,7 +391,7 @@ def test_engine_updates_progression_on_tick(self) -> None: engine = SimEngine() engine.initialize_state(world="default") - reports = engine.advance_ticks(5) + engine.advance_ticks(5) # Progression should be updated after ticks assert engine.state.progression is not None @@ -504,15 +500,30 @@ def test_all_specializations_exist(self) -> None: def test_specialization_domain_mapping(self) -> None: from gengine.echoes.core.progression import ( - AgentSpecialization, SPECIALIZATION_DOMAIN_MAP, + AgentSpecialization, ) - assert SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.NEGOTIATOR] == SkillDomain.DIPLOMACY - assert SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.INVESTIGATOR] == SkillDomain.INVESTIGATION - assert SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.ANALYST] == SkillDomain.ECONOMICS - assert SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.OPERATOR] == SkillDomain.TACTICAL - assert SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.INFLUENCER] == SkillDomain.INFLUENCE + assert ( + SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.NEGOTIATOR] + == SkillDomain.DIPLOMACY + ) + assert ( + SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.INVESTIGATOR] + == SkillDomain.INVESTIGATION + ) + assert ( + SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.ANALYST] + == SkillDomain.ECONOMICS + ) + assert ( + SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.OPERATOR] + == SkillDomain.TACTICAL + ) + assert ( + SPECIALIZATION_DOMAIN_MAP[AgentSpecialization.INFLUENCER] + == SkillDomain.INFLUENCE + ) class TestAgentProgressionState: @@ -564,8 +575,8 @@ def test_add_expertise(self) -> None: def test_add_expertise_caps_at_max(self) -> None: from gengine.echoes.core.progression import ( - AgentProgressionState, EXPERTISE_MAX_PIPS, + AgentProgressionState, ) agent = AgentProgressionState(agent_id="agent-1") @@ -631,12 +642,27 @@ def test_stress_label(self) -> None: assert AgentProgressionState(agent_id="a", stress=0.0).stress_label() == "calm" assert AgentProgressionState(agent_id="a", stress=0.2).stress_label() == "calm" - assert AgentProgressionState(agent_id="a", stress=0.3).stress_label() == "focused" - assert AgentProgressionState(agent_id="a", stress=0.5).stress_label() == "focused" - assert AgentProgressionState(agent_id="a", stress=0.6).stress_label() == "strained" - assert AgentProgressionState(agent_id="a", stress=0.75).stress_label() == "strained" - assert AgentProgressionState(agent_id="a", stress=0.8).stress_label() == "burned out" - assert AgentProgressionState(agent_id="a", stress=1.0).stress_label() == "burned out" + assert ( + AgentProgressionState(agent_id="a", stress=0.3).stress_label() == "focused" + ) + assert ( + AgentProgressionState(agent_id="a", stress=0.5).stress_label() == "focused" + ) + assert ( + AgentProgressionState(agent_id="a", stress=0.6).stress_label() == "strained" + ) + assert ( + AgentProgressionState(agent_id="a", stress=0.75).stress_label() + == "strained" + ) + assert ( + AgentProgressionState(agent_id="a", stress=0.8).stress_label() + == "burned out" + ) + assert ( + AgentProgressionState(agent_id="a", stress=1.0).stress_label() + == "burned out" + ) def test_role_label_rookie(self) -> None: from gengine.echoes.core.progression import ( @@ -787,7 +813,8 @@ def test_no_agent_returns_global_modifier(self) -> None: prog = ProgressionState() mod = calculate_agent_modifier(prog, None) - # With level 1 skills, the modifier is 1.0 - 0.25 = 0.75 due to skill contribution + # With level 1 skills, the modifier is 1.0 - 0.25 = 0.75 due to skill + # contribution assert 0.7 <= mod <= 1.0 def test_expertise_adds_bonus(self) -> None: @@ -906,9 +933,7 @@ def test_tick_creates_agent_progression(self) -> None: ) state = load_world_bundle() - system = ProgressionSystem( - per_agent_settings=PerAgentProgressionSettings() - ) + system = ProgressionSystem(per_agent_settings=PerAgentProgressionSettings()) agent_actions = [ {"intent": "INSPECT_DISTRICT", "agent_id": "agent-1"}, diff --git a/tests/echoes/test_service_api.py b/tests/echoes/test_service_api.py index bb0e0f3f..540eed5c 100644 --- a/tests/echoes/test_service_api.py +++ b/tests/echoes/test_service_api.py @@ -73,7 +73,11 @@ def test_state_endpoint_returns_post_mortem() -> None: } assert expected_keys <= payload.keys() assert payload["environment"] - assert set(payload["environment_trend"]["delta"]) == {"stability", "unrest", "pollution"} + assert set(payload["environment_trend"]["delta"]) == { + "stability", + "unrest", + "pollution", + } assert isinstance(payload["faction_trends"], list) assert isinstance(payload["featured_events"], list) assert isinstance(payload["story_seeds"], list) @@ -126,6 +130,8 @@ def test_focus_endpoint_reports_state_and_validates() -> None: bad_response = client.post("/focus", json={"district_id": "unknown"}) assert bad_response.status_code == 400 - good_response = client.post("/focus", json={"district_id": payload["focus"]["district_id"]}) + good_response = client.post( + "/focus", json={"district_id": payload["focus"]["district_id"]} + ) assert good_response.status_code == 200 assert "history" in good_response.json() diff --git a/tests/echoes/test_service_client.py b/tests/echoes/test_service_client.py index 83a69b82..34a30745 100644 --- a/tests/echoes/test_service_client.py +++ b/tests/echoes/test_service_client.py @@ -40,7 +40,7 @@ def test_client_state_and_metrics() -> None: def test_client_submit_actions() -> None: client = build_client() - response = client.submit_actions([{ "intent": "noop" }]) + response = client.submit_actions([{"intent": "noop"}]) assert response["results"][0]["status"] == "noop" client.close() diff --git a/tests/echoes/test_settings.py b/tests/echoes/test_settings.py index 8e9ca781..39a30b77 100644 --- a/tests/echoes/test_settings.py +++ b/tests/echoes/test_settings.py @@ -12,8 +12,15 @@ def test_load_simulation_config_from_custom_root(tmp_path: Path) -> None: config_path = tmp_path / "simulation.yml" config_path.write_text( - "limits:\n cli_run_cap: 7\n engine_max_ticks: 10\n cli_script_command_cap: 4\n service_tick_cap: 8\n" - "lod:\n mode: detailed\n\nprofiling:\n log_ticks: false\n" + "limits:\n" + " cli_run_cap: 7\n" + " engine_max_ticks: 10\n" + " cli_script_command_cap: 4\n" + " service_tick_cap: 8\n" + "lod:\n" + " mode: detailed\n\n" + "profiling:\n" + " log_ticks: false\n" ) config = load_simulation_config(config_root=tmp_path) diff --git a/tests/echoes/test_sim_engine.py b/tests/echoes/test_sim_engine.py index 9c605ea7..548e62d6 100644 --- a/tests/echoes/test_sim_engine.py +++ b/tests/echoes/test_sim_engine.py @@ -95,4 +95,4 @@ def test_engine_query_post_mortem_view() -> None: payload = engine.query_view("post-mortem") assert payload["tick"] >= 0 - assert "environment" in payload \ No newline at end of file + assert "environment" in payload diff --git a/tests/echoes/test_snapshot_persistence.py b/tests/echoes/test_snapshot_persistence.py index d6b8223b..154ce8e4 100644 --- a/tests/echoes/test_snapshot_persistence.py +++ b/tests/echoes/test_snapshot_persistence.py @@ -7,7 +7,11 @@ import pytest from gengine.echoes.content import load_world_bundle -from gengine.echoes.persistence.snapshot import _json_default, load_snapshot, save_snapshot +from gengine.echoes.persistence.snapshot import ( + _json_default, + load_snapshot, + save_snapshot, +) def test_json_default_rejects_unknown_type() -> None: diff --git a/tests/echoes/test_story_seeds.py b/tests/echoes/test_story_seeds.py index 72b9ba3c..dd194455 100644 --- a/tests/echoes/test_story_seeds.py +++ b/tests/echoes/test_story_seeds.py @@ -142,7 +142,9 @@ def test_story_seed_persists_during_cooldown_window() -> None: seeds = analysis.get("story_seeds") or [] assert seeds - energy_seed = next(seed for seed in seeds if seed["seed_id"] == "energy-quota-crisis") + energy_seed = next( + seed for seed in seeds if seed["seed_id"] == "energy-quota-crisis" + ) cooldown = state.story_seeds["energy-quota-crisis"].cooldown_ticks assert energy_seed["last_trigger_tick"] == first_tick assert energy_seed["cooldown_remaining"] == cooldown - (followup_tick - first_tick) diff --git a/tests/scripts/test_analyze_difficulty_profiles.py b/tests/scripts/test_analyze_difficulty_profiles.py index 8b671ffd..3d9f7277 100644 --- a/tests/scripts/test_analyze_difficulty_profiles.py +++ b/tests/scripts/test_analyze_difficulty_profiles.py @@ -3,13 +3,15 @@ from __future__ import annotations import json +import sys from importlib import util from pathlib import Path -import sys import pytest -_MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "analyze_difficulty_profiles.py" +_MODULE_PATH = ( + Path(__file__).resolve().parents[2] / "scripts" / "analyze_difficulty_profiles.py" +) def _load_analysis_module(): @@ -225,7 +227,9 @@ def test_analysis_main_json_output( assert "comparison" in data -def test_analysis_main_no_telemetry(tmp_path: Path, capsys: pytest.CaptureFixture) -> None: +def test_analysis_main_no_telemetry( + tmp_path: Path, capsys: pytest.CaptureFixture +) -> None: """Test CLI with no telemetry files.""" exit_code = main(["--telemetry-dir", str(tmp_path)]) diff --git a/tests/scripts/test_run_difficulty_sweeps.py b/tests/scripts/test_run_difficulty_sweeps.py index aabf5e1a..748bc7dd 100644 --- a/tests/scripts/test_run_difficulty_sweeps.py +++ b/tests/scripts/test_run_difficulty_sweeps.py @@ -3,13 +3,15 @@ from __future__ import annotations import json +import sys from importlib import util from pathlib import Path -import sys import pytest -_MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts" / "run_difficulty_sweeps.py" +_MODULE_PATH = ( + Path(__file__).resolve().parents[2] / "scripts" / "run_difficulty_sweeps.py" +) def _load_sweep_module(): @@ -33,9 +35,22 @@ def minimal_config(tmp_path: Path) -> Path: config_root = tmp_path / "config" config_root.mkdir() (config_root / "simulation.yml").write_text( - "limits:\n engine_max_ticks: 5\n cli_run_cap: 5\n cli_script_command_cap: 10\n service_tick_cap: 5\n" - "lod:\n mode: balanced\n max_events_per_tick: 4\n volatility_scale:\n detailed: 1.0\n balanced: 0.8\n coarse: 0.5\n" - "profiling:\n log_ticks: false\n history_window: 5\n capture_subsystems: true\n" + "limits:\n" + " engine_max_ticks: 5\n" + " cli_run_cap: 5\n" + " cli_script_command_cap: 10\n" + " service_tick_cap: 5\n" + "lod:\n" + " mode: balanced\n" + " max_events_per_tick: 4\n" + " volatility_scale:\n" + " detailed: 1.0\n" + " balanced: 0.8\n" + " coarse: 0.5\n" + "profiling:\n" + " log_ticks: false\n" + " history_window: 5\n" + " capture_subsystems: true\n" ) return config_root @@ -49,9 +64,22 @@ def difficulty_configs(tmp_path: Path) -> Path: preset_dir = sweeps_dir / f"difficulty-{preset}" preset_dir.mkdir(parents=True) (preset_dir / "simulation.yml").write_text( - "limits:\n engine_max_ticks: 3\n cli_run_cap: 3\n cli_script_command_cap: 5\n service_tick_cap: 3\n" - "lod:\n mode: balanced\n max_events_per_tick: 4\n volatility_scale:\n detailed: 1.0\n balanced: 0.8\n coarse: 0.5\n" - "profiling:\n log_ticks: false\n history_window: 5\n capture_subsystems: true\n" + "limits:\n" + " engine_max_ticks: 3\n" + " cli_run_cap: 3\n" + " cli_script_command_cap: 5\n" + " service_tick_cap: 3\n" + "lod:\n" + " mode: balanced\n" + " max_events_per_tick: 4\n" + " volatility_scale:\n" + " detailed: 1.0\n" + " balanced: 0.8\n" + " coarse: 0.5\n" + "profiling:\n" + " log_ticks: false\n" + " history_window: 5\n" + " capture_subsystems: true\n" ) return sweeps_dir.parent.parent.parent @@ -154,13 +182,19 @@ def test_sweep_main_cli( monkeypatch.chdir(Path(__file__).resolve().parents[2]) - exit_code = main([ - "--ticks", "3", - "--seed", "42", - "--output-dir", str(output_dir), - "--preset", "normal", - "--quiet", - ]) + exit_code = main( + [ + "--ticks", + "3", + "--seed", + "42", + "--output-dir", + str(output_dir), + "--preset", + "normal", + "--quiet", + ] + ) assert exit_code == 0 captured = capsys.readouterr() @@ -175,14 +209,20 @@ def test_sweep_main_json_output( monkeypatch.chdir(Path(__file__).resolve().parents[2]) - exit_code = main([ - "--ticks", "3", - "--seed", "42", - "--output-dir", str(output_dir), - "--preset", "normal", - "--quiet", - "--json", - ]) + exit_code = main( + [ + "--ticks", + "3", + "--seed", + "42", + "--output-dir", + str(output_dir), + "--preset", + "normal", + "--quiet", + "--json", + ] + ) assert exit_code == 0 captured = capsys.readouterr() diff --git a/tests/scripts/test_run_headless_sim.py b/tests/scripts/test_run_headless_sim.py index be5b3686..35493633 100644 --- a/tests/scripts/test_run_headless_sim.py +++ b/tests/scripts/test_run_headless_sim.py @@ -3,9 +3,9 @@ from __future__ import annotations import json +import sys from importlib import util from pathlib import Path -import sys import pytest @@ -31,14 +31,29 @@ def minimal_config(tmp_path: Path) -> Path: config_root = tmp_path / "config" config_root.mkdir() (config_root / "simulation.yml").write_text( - "limits:\n engine_max_ticks: 2\n cli_run_cap: 2\n cli_script_command_cap: 5\n service_tick_cap: 2\n" - "lod:\n mode: balanced\n max_events_per_tick: 4\n volatility_scale:\n detailed: 1.0\n balanced: 0.8\n coarse: 0.5\nprofiling:\n log_ticks: false\n" - " history_window: 5\n capture_subsystems: true\n" + "limits:\n" + " engine_max_ticks: 2\n" + " cli_run_cap: 2\n" + " cli_script_command_cap: 5\n" + " service_tick_cap: 2\n" + "lod:\n" + " mode: balanced\n" + " max_events_per_tick: 4\n" + " volatility_scale:\n" + " detailed: 1.0\n" + " balanced: 0.8\n" + " coarse: 0.5\n" + "profiling:\n" + " log_ticks: false\n" + " history_window: 5\n" + " capture_subsystems: true\n" ) return config_root -def test_run_headless_sim_supports_batches(tmp_path: Path, minimal_config: Path) -> None: +def test_run_headless_sim_supports_batches( + tmp_path: Path, minimal_config: Path +) -> None: output = tmp_path / "report.json" summary = run_headless_sim( @@ -91,7 +106,9 @@ def test_run_headless_sim_supports_batches(tmp_path: Path, minimal_config: Path) assert data["post_mortem"]["environment"] -def test_headless_cli_entrypoint(monkeypatch, capsys, minimal_config: Path, tmp_path: Path) -> None: +def test_headless_cli_entrypoint( + monkeypatch, capsys, minimal_config: Path, tmp_path: Path +) -> None: output = tmp_path / "batch.json" exit_code = headless_main( [ @@ -108,7 +125,7 @@ def test_headless_cli_entrypoint(monkeypatch, capsys, minimal_config: Path, tmp_ assert exit_code == 0 captured = capsys.readouterr() - assert "\"ticks_requested\": 1" in captured.out + assert '"ticks_requested": 1' in captured.out assert output.exists() saved = json.loads(output.read_text()) assert "agent_actions" in saved @@ -117,4 +134,4 @@ def test_headless_cli_entrypoint(monkeypatch, capsys, minimal_config: Path, tmp_ assert "last_event_digest" in saved assert "ranked_archive" in saved["last_event_digest"] assert "director_feed" in saved - assert "post_mortem" in saved \ No newline at end of file + assert "post_mortem" in saved