Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ Tick-specific priorities injected into all Felix agents (other harnesses get no
### Save Loading + Item Setup

`run_scenario.py` automatically:
1. Loads the scenario's save file (`rle_crashlanded_v1`, etc.)
1. Loads the scenario's save file (`rle_crashlanded_v1` seeds a built `SimpleResearchBench` at (128,136) and queues `Smithing`; the other five saves are derived from this base)
2. Polls until game is ready (colonist_count > 0)
3. Unforbids all starting items (via `POST /api/v1/things/set-forbidden`)
4. Runs any `setup_commands` declared in the scenario YAML (spawn_pawn, spawn_item, change_weather, drop_pod)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ You need four things set up:
1. **RimWorld** (Steam) with **Harmony** and **[RIMAPI](https://github.com/IlyaChichkov/RIMAPI)** mods subscribed and **enabled** in the Mods menu. Load order: Harmony → Core → (DLCs) → RIMAPI.
2. **LLM provider** — [LM Studio](https://lmstudio.ai/) (local, free) or [OpenRouter](https://openrouter.ai/) (cloud)
3. **Python 3.14+** with [uv](https://docs.astral.sh/uv/)
4. **Save file** — `rle_crashlanded_v1` in RimWorld's save folder (the scenario auto-loads it)
4. **Save file** — `rle_crashlanded_v1` in RimWorld's save folder (the scenario auto-loads it). The Crashlanded seed includes a built `SimpleResearchBench` so research can leave the 7/31 starting floor.

> **RIMAPI note:** The Workshop version may not have our contributed endpoints yet. See [CLAUDE.md](CLAUDE.md) for instructions on building and deploying our fork DLL. AppSprout runs set `RIMAPI_DLL_PATH` and `RIMAPI_FORK_PATH` to the compiled checkout; summaries record that path, the DLL SHA, and the fork commit. Workshop is not source of truth.

Expand Down
19 changes: 18 additions & 1 deletion docker/saves/rle_crashlanded_v1.rws
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@
<letterQueue />
</letterStack>
<researchManager>
<currentProj>Smithing</currentProj>
<progress>
<keys>
<li>CataphractArmor</li>
Expand Down Expand Up @@ -2652,7 +2653,7 @@ AAAAAA==
<lastSeason>Spring</lastSeason>
</dateNotifier>
<uniqueIDsManager>
<nextThingID>37771</nextThingID>
<nextThingID>37772</nextThingID>
<nextFactionID>12</nextFactionID>
<nextTaleID>4</nextTaleID>
<nextWorldObjectID>93</nextWorldObjectID>
Expand Down Expand Up @@ -458687,6 +458688,22 @@ Hw==
<duplicate IsNull="True" />
<flight />
</thing>
<thing Class="Building_ResearchBench">
<def>SimpleResearchBench</def>
<id>SimpleResearchBench37771</id>
<map>0</map>
<pos>(128, 0, 136)</pos>
<health>180</health>
<stuff>WoodLog</stuff>
<faction>Faction_11</faction>
<questTags IsNull="True" />
<spawnedTick>0</spawnedTick>
<despawnedTick>-1</despawnedTick>
<beenRevealed>True</beenRevealed>
<billStack>
<bills />
</billStack>
</thing>
</things>
</li>
</maps>
Expand Down
8 changes: 8 additions & 0 deletions saves/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Compressed RimWorld save files used by the benchmark scenarios.

`rle_crashlanded_v1` seeds a built, player-owned `SimpleResearchBench`
(wood, `(128, 0, 136)`, no power required) and queues `currentProj=Smithing`.
Without the bench, scoring 1.2 research floors at **7/31 ≈ 0.2258** — the
starting finished/available ratio — because colonists cannot finish tech and
`research_target` is rejected as bench/prereqs missing. That floor is not σ.
The other five scenario saves are derived from this base; rebuild them via
`scripts/create_scenario_saves.py` if they need the same bench.

## Install

Extract to your RimWorld saves folder:
Expand Down
Binary file modified saves/rle_crashlanded_v1.rws.gz
Binary file not shown.
3 changes: 3 additions & 0 deletions scripts/create_scenario_saves.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
Prerequisites:
- RimWorld running with RIMAPI mod loaded
- Base save `rle_crashlanded_v1` exists and loads cleanly
(seeded SimpleResearchBench + currentProj=Smithing)

Usage:
python scripts/create_scenario_saves.py # build all
Expand Down Expand Up @@ -83,6 +84,8 @@ def _default_rimworld_save_dir() -> Path:

# Per-scenario setup recipe. Each scenario is a sequence of API calls.
# `items` entries are (def_name, amount[, stuff_def]) tuples.
# The Crashlanded base save seeds a built SimpleResearchBench and queues
# currentProj=Smithing so research can leave the 7/31 starting floor.
# NOTE on "day advancement": the plan (and issue #7) originally called for
# building saves at day 30 / 60 / etc with shelter, food, and research
# progress. RIMAPI does not currently expose an endpoint to fast-forward
Expand Down
17 changes: 16 additions & 1 deletion src/rle/harness/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from pydantic import BaseModel, ConfigDict

from rle.rimapi.api_catalog import visible_write_catalog
from rle.rimapi.schemas import GameState
from rle.rimapi.schemas import GameState, is_research_bench_def
from rle.rimapi.sse_client import RimAPIEvent
from rle.scenarios.schema import ScenarioConfig

Expand All @@ -25,6 +25,17 @@
)
_MAX_EVENTS = 12

_MISSING_BENCH_CUE = (
"RESEARCH: no research bench on the map — colonists cannot finish tech "
"(research score stays at the seed floor). Blueprint SimpleResearchBench "
"before research_target, or the write fails with bench/prereqs missing."
)


def research_bench_present(state: GameState) -> bool:
"""True when GameState.structures includes a research bench."""
return any(is_research_bench_def(s.def_name) for s in state.map.structures)


def build_map_summary(state: GameState) -> str | None:
"""Compact (~500 token) spatial summary from terrain + zone + room data.
Expand Down Expand Up @@ -102,6 +113,9 @@ def build_map_summary(state: GameState) -> str | None:
f"{fs.harvestable_cells} harvestable."
)

if not research_bench_present(state):
lines.append(_MISSING_BENCH_CUE)

return "\n".join(lines)


Expand Down Expand Up @@ -143,6 +157,7 @@ def state_snapshot(state: GameState) -> dict[str, Any]:
],
"resources": state.resources.model_dump(),
"research": state.research.model_dump(),
"research_bench_present": research_bench_present(state),
"threats": [t.model_dump() for t in state.threats],
"weather": state.weather.model_dump(),
"map": {
Expand Down
3 changes: 3 additions & 0 deletions src/rle/orchestration/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ async def _h_research_target(self, cid: str, params: dict[str, Any]) -> None:
research = None if self._tick_state is None else self._tick_state.research
if research is not None and not force:
status = research_target_status(project, research)
if status == "current":
# Already queued — same success-by-state as a covered growing zone.
return
if status == "finished":
raise ValueError(
f"Research project '{project}' is already finished."
Expand Down
3 changes: 2 additions & 1 deletion src/rle/orchestration/preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

* growing-zone cells already covered → success-by-state
* work_priority payloads aligned with WorkTypeDef / ``/api/v1/work-list``
* research_target only when the project is available (prereqs / bench)
* research_target only when the project is available (prereqs / bench);
current project is already-satisfied (no rewrite)
* tend requires a living doctor + patient pair
* stockpile_delete is quarantined when the endpoint is missing
"""
Expand Down
30 changes: 28 additions & 2 deletions src/rle/rimapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,36 @@
ThreatData,
WeatherData,
ZoneData,
is_research_bench_def,
)

logger = logging.getLogger(__name__)

# GameState.structures is a prompt-sized sample. Research benches must stay
# in that sample even when the map has 400+ ruin walls, or the brief flag
# research_bench_present is a false negative.
_STRUCTURE_SAMPLE_LIMIT = 50


def _building_def_name(building: dict[str, Any]) -> str:
return str(building.get("def_name", building.get("label", "Unknown")))


def _select_buildings_for_state(buildings: Any) -> list[Any]:
"""Research benches first, then other buildings, capped for the snapshot."""
if not isinstance(buildings, list):
return []
benches: list[Any] = []
rest: list[Any] = []
for raw in buildings:
if not isinstance(raw, dict):
continue
if is_research_bench_def(_building_def_name(raw)):
benches.append(raw)
else:
rest.append(raw)
return benches + rest


class RimAPIError(Exception):
"""Base exception for RIMAPI errors."""
Expand Down Expand Up @@ -586,7 +612,7 @@ async def get_map(self) -> MapData:
except (RimAPIResponseError, RimAPIConnectionError):
temperature = 15.0
structures = []
for b in buildings[:50]:
for b in _select_buildings_for_state(buildings)[:_STRUCTURE_SAMPLE_LIMIT]:
pos = b.get("position", [0, 0])
if isinstance(pos, dict):
pos = (pos.get("x", 0), pos.get("z", 0))
Expand All @@ -596,7 +622,7 @@ async def get_map(self) -> MapData:
pos = (0, 0)
structures.append(StructureData(
structure_id=str(b.get("id", b.get("thing_id", ""))),
def_name=b.get("def_name", b.get("label", "Unknown")),
def_name=_building_def_name(b),
position=pos,
hit_points=float(b.get("hit_points", 100)),
max_hit_points=float(b.get("max_hit_points", 100)),
Expand Down
10 changes: 10 additions & 0 deletions src/rle/rimapi/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ class StructureData(BaseModel):
max_hit_points: float


def is_research_bench_def(def_name: str) -> bool:
"""True for vanilla research benches and close aliases.

SimpleResearchBench does not need power. HiTechResearchBench does.
Matching is case-insensitive and ignores underscores so ``Research_Bench``
still counts.
"""
return "researchbench" in def_name.lower().replace("_", "")


class ColonistData(BaseModel):
"""Snapshot of a single colonist's state."""

Expand Down
4 changes: 2 additions & 2 deletions src/rle/scenarios/definitions/01_crashlanded_survival.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Crashlanded Survival
description: Three colonists crash-land with minimal supplies. Survive 30 days.
description: Three colonists crash-land with minimal supplies and a seeded simple research bench. Survive 30 days.
difficulty: easy
expected_duration_days: 30
initial_population: 3
Expand All @@ -26,4 +26,4 @@ scoring_weights:
self_sufficiency: 0.16
efficiency: 0.04
plan_coherence: 0.08
save_sha256: 29530cd8e5f373b50f9ee51617bc0b036de97f95c9fd3264a2bbd5939a133d4e
save_sha256: 17835b77622ca633228b00f7616af2cc45198b51fbb5169adc8233370e0d4bbf
5 changes: 4 additions & 1 deletion src/rle/testing/scripted_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@

DEFAULT_SCRIPT: tuple[tuple[str, dict[str, Any]], ...] = (
("get_brief", {}),
("research_target", {"parameters": {"project": "Electricity"}, "reason": "smoke"}),
# Mock / live snapshots often already have Electricity queued. Targeting
# the current project is success-by-state and would skip the RIMAPI write
# this script exists to exercise. Smithing is available, not current.
("research_target", {"parameters": {"project": "Smithing"}, "reason": "smoke"}),
("end_turn", {"summary": "scripted smoke turn"}),
)

Expand Down
33 changes: 30 additions & 3 deletions tests/unit/test_brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,31 @@

from __future__ import annotations

from rle.harness.brief import action_catalog, build_brief, build_map_summary
from rle.harness.brief import (
action_catalog,
build_brief,
build_map_summary,
research_bench_present,
)
from rle.rimapi.schemas import (
AreaRect,
ColonyData,
GameState,
MapData,
ResearchData,
ResourceData,
StructureData,
TerrainSummary,
WeatherData,
)
from rle.rimapi.sse_client import RimAPIEvent
from rle.scenarios.loader import list_scenarios


def _state(with_terrain: bool = True) -> GameState:
def _state(
with_terrain: bool = True,
structures: list[StructureData] | None = None,
) -> GameState:
terrain = TerrainSummary(
colony_center=(50, 50),
recommended_shelter=AreaRect(x1=40, z1=40, x2=46, z2=46),
Expand All @@ -36,7 +45,7 @@ def _state(with_terrain: bool = True) -> GameState:
),
map=MapData(
size=(250, 250), biome="temperate_forest", season="spring",
temperature=15.0, structures=[], terrain=terrain,
temperature=15.0, structures=list(structures or []), terrain=terrain,
),
research=ResearchData(current_project=None, progress=0.0, completed=[], available=["a"]),
threats=[],
Expand All @@ -57,6 +66,23 @@ def test_contains_verified_sites_and_water(self) -> None:
assert "STOCKPILE SITE" in text
assert "WATER (do NOT build here)" in text
assert "Zones: NONE" in text and "Rooms: NONE" in text
assert "RESEARCH: no research bench" in text

def test_research_cue_omitted_when_bench_present(self) -> None:
bench = StructureData(
structure_id="SimpleResearchBench37771",
def_name="SimpleResearchBench",
position=(128, 136),
hit_points=180.0,
max_hit_points=180.0,
)
state = _state(structures=[bench])
text = build_map_summary(state)
assert text is not None
assert "RESEARCH: no research bench" not in text
assert research_bench_present(state) is True
brief = build_brief(state, tick=0, macro_time=0.0)
assert brief.state["research_bench_present"] is True


class TestActionCatalog:
Expand All @@ -81,6 +107,7 @@ def test_brief_carries_goals_state_events_and_actions(self) -> None:
assert brief.goals["name"] == scenario.name
assert brief.goals["victory"]
assert brief.state["colony"]["population"] == 3
assert brief.state["research_bench_present"] is False
assert brief.recent_events[0]["event_type"] == "raid"
assert brief.map_summary and "SHELTER SITE" in brief.map_summary
text = brief.to_text()
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/test_crashlanded_save.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Contract tests for the Crashlanded seed (research bench + gzip sibling)."""

from __future__ import annotations

import gzip
from pathlib import Path

from rle.scenarios.loader import canonical_save_path, load_scenario

REPO_ROOT = Path(__file__).resolve().parents[2]
DEFINITIONS = REPO_ROOT / "src" / "rle" / "scenarios" / "definitions"
GZ_SAVE = REPO_ROOT / "saves" / "rle_crashlanded_v1.rws.gz"


def test_crashlanded_save_has_simple_research_bench() -> None:
path = canonical_save_path("rle_crashlanded_v1")
raw = path.read_bytes()
text = raw.decode("utf-8-sig")
assert "<def>SimpleResearchBench</def>" in text
assert 'Class="Building_ResearchBench"' in text
assert "<currentProj>Smithing</currentProj>" in text
assert "<pos>(128, 0, 136)</pos>" in text
assert "ResearchBench" in text


def test_crashlanded_gzip_round_trips_canonical_rws() -> None:
raw = canonical_save_path("rle_crashlanded_v1").read_bytes()
assert GZ_SAVE.is_file()
assert gzip.decompress(GZ_SAVE.read_bytes()) == raw


def test_crashlanded_yaml_pin_matches_canonical_save() -> None:
scenario = load_scenario(DEFINITIONS / "01_crashlanded_survival.yaml")
assert scenario.save_name == "rle_crashlanded_v1"
assert scenario.save_sha256
assert len(scenario.save_sha256) == 64
13 changes: 13 additions & 0 deletions tests/unit/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,19 @@ def test_empty_tree(self) -> None:
s = _state(completed_research=[], available_research=[])
assert research(s, _ctx()) == pytest.approx(1.0)

def test_crashlanded_seed_floor_is_seven_of_thirty_one(self) -> None:
"""Crashlanded starts at 7 finished / 24 available = 7/31 ≈ 0.2258.

That ratio is the save's starting tree, not model variance. Do not
treat the floor as σ. Completing a project requires a research bench.
"""
s = _state(
completed_research=[f"done_{i}" for i in range(7)],
available_research=[f"open_{i}" for i in range(24)],
)
assert research(s, _ctx()) == pytest.approx(7 / 31)
assert research(s, _ctx()) == pytest.approx(0.2258, abs=5e-5)


class TestSelfSufficiency:
def test_all_good(self) -> None:
Expand Down
Loading
Loading