Skip to content

Full RIMAPI integration: fix data gaps, alerts, incident control, scenario pipeline#18

Merged
jkbennitt merged 13 commits intomasterfrom
feature/rimapi-full-integration
Apr 13, 2026
Merged

Full RIMAPI integration: fix data gaps, alerts, incident control, scenario pipeline#18
jkbennitt merged 13 commits intomasterfrom
feature/rimapi-full-integration

Conversation

@jkbennitt
Copy link
Copy Markdown
Member

@jkbennitt jkbennitt commented Apr 13, 2026

Summary

Wires every useful RIMAPI endpoint into the RLE client and fixes the data gaps that were hurting agent decisions. Makes partial progress on #7's save creation pipeline by rewriting it to use RIMAPI (replacing XML surgery), but does NOT fully close #7 — see "What's still needed for #7" below.

  • Phase 1 — Fix data gaps: steel, wood, components, power_net were hardcoded to 0 in get_resources(). Agents skipped research benches because they thought they had no wood. Now wired to /api/v1/resources/stored and /api/v1/map/power/info. Also wired faction goodwill and fixed the equip/repair_rect/destroy_rect actions that were in the catalog but silently failing.
  • Phase 2 — Agent awareness + deterministic scenarios: alerts endpoint (/api/v1/ui/alerts) gives agents structured problem signals like "Need Beds" / "Colonist Starving" with target IDs. Incident trigger lets scenarios fire ToxicFallout/RaidEnemy/Plague deterministically instead of hoping the storyteller cooperates. Screenshots opt-in via screenshots_enabled.
  • Phase 3 — Spawn + setup: spawn_pawn, spawn_item, send_drop_pod, change_weather plus SetupCommand in scenario YAMLs. Rewrote create_scenario_saves.py — 200+ lines of XML regex surgery replaced with declarative RIMAPI calls.
  • Phase 4 — Pawn edit helpers: edit_pawn_skills, edit_pawn_traits, edit_pawn_health, edit_pawn_needs for scenario customization.

What scenario saves got built (and what they still lack)

All 5 advanced saves were rebuilt via RIMAPI and committed to docker/saves/. They have the item/pawn/incident state from setup commands, but they're still day-0 snapshots because RIMAPI has no endpoint to fast-forward the game clock:

Save ✅ Got ❌ Still missing vs #7 spec
first_winter Medium difficulty Not advanced to day 30; no shelter/food/research buildup
toxic_fallout +1 pawn, wood/meals/steel, ToxicFallout active Not advanced to day 10 stable colony first
raid_defense +2 pawns, weapons, flak vests, resources Not advanced to day 15; no walls/sandbags built
plague_response +2 pawns, medicine, meals (Plague fires at scenario runtime) Not advanced to day 10; plague hediffs not pre-applied
ship_launch +2 pawns, high-tier resources (plasteel/gold/uranium) Not advanced to day 60; research still mostly incomplete

What's still needed for #7

This PR delivers the client-side infrastructure for save creation but not the advanced colony states the issue spec originally called for. The remaining work needs either:

  1. A RIMAPI tick-advance endpoint — fast-forward N ticks, then save. Would unblock "day 30" / "day 60" scenarios in one API call. File as feature request upstream.
  2. Orchestrated in-game play — run an agent colony for N real-time ticks, then save. Slow but uses existing APIs. Could be a new script.
  3. Manual dev-mode setup — keep the original manual workflow for these 5 saves as a one-time thing. Not sustainable.

set_research_target(force=True) only queues a project, it doesn't instantly complete it — noted in the create_scenario_saves.py comments so future sessions don't try to reuse that approach for advancement.

How this moves the needle on #6

Problem (from #6 comment-4159780616) Fixed by
Mood 0.40 — agents don't prioritize beds/cooking Phase 2a alerts expose "Need Beds" / starvation with target IDs
Research 0.226 — bench rarely placed Phase 1a real wood/steel counts unblock construction decisions
Need harder scenarios where baseline struggles Phase 2b deterministic incident triggers + incident prediction

Notable discoveries during live testing

  • RIMAPI's save_game() returns before Unity flushes the file to disk — without polling we got 67 KB truncated writes. Script now polls file size until it stabilizes above 5 MB.
  • RIMAPI's spawn_item can't auto-split amounts above max_stack — it null-refs and destabilizes the game. Script splits into MAX_STACK[def_name] chunks.
  • Back-to-back load_game() calls fail — previous map needs time to tear down. Script adds 8s between scenarios and 10s after load before first write command.
  • equip was in the agent system prompt + WRITE_CATALOG but had no client method or executor handler. Agents proposed it and it silently fell through to generic dispatch with wrong params.

Test plan

  • 376 tests pass (added 28 new: Phase 1-4 coverage + review-gap tests + perf regression for the power_info double-fetch)
  • mypy src/ strict clean
  • ruff check src/ tests/ scripts/ clean
  • Smoke test runs all 6 scenarios without error
  • End-to-end live verification: all 5 scenario saves built via RIMAPI, reload cleanly, have expected populations (4/5/5/5/3) and wealth increases matching spawned items
  • Integration tests for _fire_scheduled_incidents with MockClient assertions

🤖 Generated with Claude Code

jkbennitt and others added 10 commits April 12, 2026 21:37
…stroy

Agents were deciding with incomplete data — steel=0, wood=0, components=0,
power_net=0.0 were all hardcoded. This caused agents to skip building
research benches (thinks no wood) and ignore power management entirely.

- Wire /api/v1/resources/stored to get real material counts (WoodLog, Steel,
  ComponentIndustrial) instead of returning 0
- Wire /api/v1/map/power/info for real power_net (current - consumption)
- Add PowerData and FactionData schemas to GameState
- Wire /api/v1/factions for faction goodwill (DefenseCommander needs this)
- Fix equip action: was in system prompt + WRITE_CATALOG but had no client
  method or executor handler — agents proposed it and it silently failed
- Wire repair_rect and destroy_rect executor handlers (same gap as equip)

Refs #6 (agents must beat baseline), #7 (save creation pipeline)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Agents saw mood=0.40 but didn't know why. Now they get structured alerts
("Need Beds", "Colonist Starving") with target colonist IDs. Scenarios
relied on the storyteller to fire their signature events — now they
trigger incidents deterministically at specific ticks.

- Wire GET /api/v1/ui/alerts → AlertData model, added to GameState
- Wire POST /api/v1/incident/trigger for programmatic incident control
- Wire GET /api/v1/incidents/top for probability-weighted predictions
- Wire POST /api/v1/camera/screenshot → ScreenshotResponse model
- Add TriggeredIncident to ScenarioConfig + _fire_scheduled_incidents()
  in game loop (fires after pause, before state refresh)
- Update scenario YAMLs: toxic_fallout fires at tick 1, raid at tick 2
  with 500 points, plague at tick 1
- Game loop: opt-in screenshots_enabled, screenshot_data_uri in tick JSON
- Add ui_alerts to READ_CATALOG, camera_screenshot to WRITE_CATALOG

Refs #6 (alerts improve agent mood responses), #7 (deterministic saves)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unblocks #7 save creation pipeline — scenarios can now spawn pawns, items,
and change weather via RIMAPI instead of XML manipulation. Setup commands
run after save load and before the game loop starts.

- Add spawn_pawn() — POST /api/v1/pawn/spawn (named colonists with position)
- Add spawn_item() — POST /api/v1/item/spawn (with optional quality/stuff)
- Add send_drop_pod() — POST /api/v1/map/droppod (items at position)
- Add change_weather() — POST /api/v1/map/weather/change
- Add SetupCommand model to ScenarioConfig (type + params dict)
- Wire setup command dispatch in run_scenario.py after unforbid
- Pass triggered_incidents from scenario config to game loop

Refs #7 (advanced save creation without manual dev mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enables scenario-specific pawn customization for the #7 save creation
pipeline. Ship launch pawns need high Construction + Research, plague
saves need specific health states, etc.

- edit_pawn_skills(pawn_id, skills, passions) — set levels and passion
- edit_pawn_traits(pawn_id, add, remove) — add/remove traits
- edit_pawn_health(pawn_id, heal_all, restore_parts, cure_diseases)
- edit_pawn_needs(pawn_id, needs) — set food/rest/mood with 0-1 validation
- Add all four to WRITE_CATALOG in api_catalog.py

These are setup/testing helpers only — not wired into agent action system.

Refs #7 (advanced save creation with custom pawn states)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaces the XML manipulation pipeline with a pure RIMAPI call sequence:
load base save → spawn items/pawns via API → trigger incidents → set
research → save. No more brittle XML surgery, no more manual dev mode.

- create_scenario_saves.py: full rewrite
  - SCENARIOS dict declares items, incidents, extra_pawns, research per save
  - build_scenario_save() loads base, applies setup, saves via client
  - --only flag to build a single scenario
  - --difficulty-only flag for offline byte-patching (no game needed)
  - Uses spawn_item, spawn_pawn, trigger_incident, set_research_target,
    save_game, load_game all through RimAPIClient
  - Dropped 200+ lines of XML regex surgery (clone_pawn, make_item_xml,
    extract_pawn_block, loadID remapping, uniqueIDsManager patching)

- run_benchmark.py: add mock routes for new endpoints (resources/stored,
  map/power/info, factions, ui/alerts) so smoke tests exercise the new
  code paths instead of falling through to error handlers

Closes #7 save creation pipeline via API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All 5 advanced saves now contain their scenario-specific state:
- toxic_fallout: ToxicFallout incident actively triggered
- raid_defense: +2 pawns, weapons, armor, resources
- plague_response: +2 pawns, medicine stockpile, meals
- ship_launch: +2 pawns, resources (Steel, Plasteel, Gold, Uranium),
  2 research targets set (more were already completed in base save)
- first_winter: clean baseline with Medium difficulty

Script fixes discovered during first live run:
- Stack splitting: spawn_item can't handle amount > max_stack; split
  WoodLog, MealSurvivalPack, etc into chunks of MAX_STACK each
- Save flush delay: save_game() returns before Unity writes the file
  to disk. Added _wait_for_save_written() to poll until size stabilizes
  above 5 MB (observed 67 KB - 1.4 MB partial writes without this)
- Load settle: load_game() returns before the map is fully usable.
  Wait 10s after colonist_count > 0 before issuing spawns
- Between-scenario pause: 8s delay between scenarios to let previous
  map tear down cleanly
- Between-op pause: 0.3s between each spawn call
- Per-scenario exception handling — one scenario failing doesn't kill
  the rest; RIMAPI alive check between runs

Closes #7 remaining checklist items (advanced save states).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The plan explicitly called for test_scheduled_incidents_fire_at_tick
but it was missing. Added 3 tests to TestScheduledIncidents:
- End-to-end: run 3 ticks with scheduled incidents at tick 0 and 2
- Null case: no incidents configured, no trigger_incident calls
- Unit-ish: _fire_scheduled_incidents() invokes client with correct args
  including tick boundary checks (fires on exact tick_offset only)

Also added /api/v1/incident/trigger to mock write routes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Plan audit identified two test names missing by literal name:
- test_alerts_empty_on_error: verifies get_alerts() returns [] when
  RIMAPI is unreachable (was functionally untested)
- test_edit_traits: plan asked for single test; split into _add + _remove
  previously. Added combined test alongside existing ones

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three lessons from rebuilding scenario saves that would have saved us
multiple cycles had we known upfront:

CLAUDE.md RIMAPI gotchas section:
- Writes are async — save_game/load_game return before Unity executes
  them. save_game specifically returns before file flush (poll size).
  load_game needs ~10s settle after colonist_count populates.
- spawn_item can't split stacks — amount > max_stack triggers a null
  ref that cascades and destabilizes the entire game
- Null-ref errors persist across calls — only game restart recovers

CLAUDE.md Save Loading section:
- Mention setup_commands dispatch in run_scenario.py
- Add pointer to create_scenario_saves.py for regenerating the 5
  advanced saves (replaces the old XML pipeline)

.claude/rules/rimapi.md: two new rules capturing the same (concise).

Memory files (separate, user-scoped, not committed):
- project_rimapi_async_writes.md — detailed gotchas + max_stack table
- reference_scenario_save_regen.md — how to run create_scenario_saves.py
- feedback_save_testing.md — updated to clarify mirror IS intentional
  for canonical scenario saves (different from ad-hoc test iteration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All issues from the self-review pass fixed in one commit:

Correctness:
- [HIGH] get_power_info() was called twice per tick (once in get_resources
  internally, once in get_game_state). Refactored get_resources to accept
  an optional power_info param; get_game_state fetches once and passes
  through. Test: TestPowerInfoNotCalledTwice asserts the call count.
- [MED] Replaced ".get(k) or default" anti-pattern with _pick() helper
  that uses explicit membership check. Legitimate zero/empty values no
  longer get silently overwritten by defaults.
- [MED] change_weather now uses JSON body instead of unescaped query
  string. Test: test_change_weather_sends_json_body verifies the body.
- [MED] first_winter documented as day-0 snapshot with explanation of
  why (no tick-advance endpoint in RIMAPI yet).
- [LOW] trigger_incident now explains the str(map_id) quirk in docstring
  (RIMAPI's TriggerIncidentRequestDto.MapId is typed as C# string).

Conventions:
- [MED] Added public client.ping() — replaces client._get() leakage in
  create_scenario_saves.py (_check_rimapi_alive) and run_scenario.py
  (the post-load wait loop now uses client.get_colony() instead).
- [LOW] Hardcoded COLONY_X, COLONY_Z (132, 137) replaced with runtime
  _compute_colony_center() — centroid of live colonist positions, with
  fallback to the old constants. Protects against silent breakage when
  the base save is regenerated.

Tests added (7 new, total now 376):
- test_change_weather_sends_json_body
- test_trigger_incident_nested_parms (raid_strategy, arrival_mode)
- test_flat_list_response (get_resources_stored with list shape)
- test_ping_returns_true_when_alive
- test_ping_returns_false_on_connection_error
- test_power_info_called_once_per_tick (perf regression test)
- test_fire_scheduled_incidents_swallows_exceptions (game loop robustness)

Not addressed (out of scope / deliberate):
- take_screenshot manual envelope unwrap: the rest of the codebase uses
  "raw dict from _post" pattern widely; changing _post semantics would
  ripple across all write methods. Left as-is with inline comment.
- .rws files in git: deliberate trade-off for Docker benchmark
  reproducibility, noted in PR description.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkbennitt
Copy link
Copy Markdown
Member Author

Self-review + fixes

Ran a review pass on this PR (since I wrote it all, wanted critical eyes). Found 7 issues ranging from a real perf bug to convention nits. All addressed in commit 851bc4c (last commit on the branch). 7 new tests added as regression coverage.

High/medium issues — fixed

Severity Issue Fix
HIGH get_power_info() called twice per tick (once inside get_resources(), once in get_game_state()) — doubles HTTP latency for power data Refactored: get_resources(power_info=None) accepts an optional pre-fetched PowerData. get_game_state fetches once, passes through. New test test_power_info_called_once_per_tick asserts call count = 1.
MED .get(k) or default anti-pattern — would erase legitimate 0 / empty values if upstream ever sent them Added _pick() helper using explicit k in data membership. Replaced pattern in get_power_info, get_factions, get_alerts, get_resources_stored.
MED change_weather used unescaped query string (?name={weather_def}) — inconsistent with body-based convention elsewhere, unsafe if weather_def ever has special chars Uses JSON body now. test_change_weather_sends_json_body verifies shape.
MED first_winter scenario was a day-0 copy of base save, not the day-30 state the plan called for Documented in SCENARIOS dict with explanation: RIMAPI has no tick-advance endpoint, set_research_target(force=True) queues but doesn't complete research. Not silently shipping as unchanged.
MED create_scenario_saves.py and run_scenario.py reached into client._get(...) (private method) Added public client.ping() -> bool. run_scenario.py now uses client.get_colony() for the load-ready poll.

Low-severity — fixed

Issue Fix
trigger_incident silently sent map_id as string Docstring now explains it's because TriggerIncidentRequestDto.MapId is typed as C# string.
Hardcoded COLONY_X, COLONY_Z = 132, 137 in create_scenario_saves.py would silently break if base save is regenerated _compute_colony_center() reads live colonist positions (centroid), falls back to constants.

Not addressed (deliberate)

  • take_screenshot manual envelope unwrap_get auto-unwraps {"data": ...} but _post doesn't, so take_screenshot does result.get("data", result) manually. Fixing this requires changing _post semantics across ~25 write methods that rely on the raw envelope (result["success"] is True). Tracked as a separate followup — too risky to bundle here.
  • .rws files committed to git — deliberate trade-off for Docker benchmark reproducibility. Noted in PR description.

Test coverage added (7 new, total 376)

  • test_change_weather_sends_json_body
  • test_trigger_incident_nested_parms (raid_strategy, arrival_mode)
  • test_flat_list_response (get_resources_stored handling both response shapes)
  • test_ping_returns_true_when_alive
  • test_ping_returns_false_on_connection_error
  • test_power_info_called_once_per_tick (perf regression)
  • test_fire_scheduled_incidents_swallows_exceptions (game loop robustness)

Verification

  • 376 tests pass
  • mypy src/ strict clean
  • ruff check src/ tests/ scripts/ clean

🤖 Generated with Claude Code

_get unwrapped the {"success": bool, "data": ...} envelope but _post
returned it raw, forcing take_screenshot to do `result.get("data", result)`
by hand. Future write endpoints that return real payloads (e.g.
spawn_pawn → {"pawn_id", "name"}) would hit the same trap.

Extract _unwrap_envelope() and apply it in both methods. Unwrap is
conditional on "data" in body, so {"success": true}-only responses pass
through intact — the 29 existing "result['success'] is True" write tests
keep working without changes. Only test_spawn_pawn needed updating since
its mock actually carries a data payload. Verified no production caller
in src/ or scripts/ inspects the success flag on writes.

call() now returns Any (consistent shape for both verbs), and write
methods that forward _post's return are annotated Any to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkbennitt
Copy link
Copy Markdown
Member Author

Pushed f377487 — unified the RIMAPI envelope unwrap between _get and _post.

The trap: _get unwrapped {"success": bool, "data": ...}data, but _post returned the raw envelope. take_screenshot worked around this with a manual result.get("data", result). Future write endpoints that return payloads (e.g. spawn_pawn{"pawn_id": 999, "name": "Val"}) would hit the same gotcha.

Why changing _post in place was safe (verified before picking this over a parallel _post_unwrapped helper):

  1. Grepped ["success"] / .get("success") repo-wide. Only hits: 30 test assertions and one unrelated event_log.py line. Zero production callers inspect success on a write method — action_executor.py:94, run_scenario.py, run_benchmark.py, create_scenario_saves.py all discard the return.
  2. The unwrap is conditional on "data" in body. {"success": true}-only responses pass through unchanged, so the 29 existing result["success"] is True assertions stayed green without edits. Only test_spawn_pawn (the one write test that mocks an actual data payload) needed its assertion updated.

Changes

  • src/rle/rimapi/client.py: extracted _unwrap_envelope() helper, applied in both _get and _post. call() now returns Any and routes both verbs through unwrapping primitives. take_screenshot loses the manual unwrap. Write methods' return types widened dict[str, Any]Any to match.
  • tests/unit/test_rimapi_client.py: new TestEnvelopeUnwrap class (cross-verb consistency + no-op-when-no-data-key); updated test_spawn_pawn to assert on the unwrapped payload.

Verification: pytest (378 passed), mypy src/ strict (clean), ruff check (clean).

- Remove duplicate _wait_for_save_written in create_scenario_saves.py
  (second definition shadowed the first — drift risk if defaults
  diverged).
- Coerce rect coords to int in _h_repair_rect / _h_destroy_rect to
  match the pattern used in _h_move (LLM output can be str).
- _h_equip now checks thing_id is None instead of falsy (thing_id=0
  would have been silently skipped).
- get_resources_stored logs a warning when items are missing
  def_name — catches upstream RIMAPI schema drift instead of
  silently dropping entries.
- Document nickname/bio_age/chrono_age wire-name remapping in
  spawn_pawn docstring.
- _default_rimworld_save_dir builds paths with chained / segments
  instead of embedded slashes in strings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkbennitt
Copy link
Copy Markdown
Member Author

Self-review pass — found 6 issues, all fixed in 26d6bcf:

Bug

  • Duplicate _wait_for_save_written in scripts/create_scenario_saves.py — second definition was shadowing the first. Deleted the redundant copy and kept the one using module constants (SAVE_WAIT_TIMEOUT, SAVE_MIN_SIZE_MB).

Minor

  • _h_repair_rect / _h_destroy_rect now coerce coords to int via int(params.get(...)), matching the pattern in _h_move. LLM output can surface as strings.
  • _h_equip now checks thing_id is None instead of falsy — thing_id=0 would have been silently skipped.
  • get_resources_stored logs a one-line warning when items are missing def_name. Previously they were silently dropped, which would mask upstream RIMAPI schema drift. Added logger = logging.getLogger(__name__) to client.py.

Nits

  • spawn_pawn docstring now calls out the nicknamenick_name / bio_agebiological_age / chrono_agechronological_age wire-name remappings so callers don't need to read the source.
  • _default_rimworld_save_dir() now chains path segments via / instead of embedding / inside strings.

Verification: ruff clean on the three changed files, mypy clean on client.py and action_executor.py, 79 unit tests pass (test_rimapi_client.py + test_action_executor.py).

The committed settings should be portable across contributors. Remove
user-specific absolute paths (leaked Windows username and local repo
layout) and dead/redundant entries; move machine-specific paths and
debugging shortcuts to .claude/settings.local.json (gitignored).

Changes:
- Remove Read(//c/Users/redmo/...) rules — those were granting read
  access to per-user absolute paths; anyone who needs cross-repo
  access now adds their own additionalDirectories in settings.local.
- Remove additionalDirectories block for the same reason.
- Drop 4 exact-match entries already covered by broader patterns or
  referencing dead flags (--dry-run was replaced by --smoke-test).
- Collapse python -m mypy invocations to a single `python -m mypy:*`.
- Add portable patterns: uv run:*, python -m uv run:*, uv pip:*,
  uv sync:*, docker compose:* (tighter than docker run:*),
  docker build:*, docker exec:*, dotnet build:*, gh pr comment:*.
- Scope the ruff auto-fix hook to *.py files — previously fired a
  process on every Edit/Write regardless of file type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jkbennitt jkbennitt merged commit 4fe7a60 into master Apr 13, 2026
6 checks passed
@jkbennitt jkbennitt deleted the feature/rimapi-full-integration branch April 13, 2026 06:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Create benchmark save files for all 6 scenarios

1 participant