diff --git a/CLAUDE.md b/CLAUDE.md index d94e686..18cc516 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/README.md b/README.md index 97b7b49..c12ff43 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docker/saves/rle_crashlanded_v1.rws b/docker/saves/rle_crashlanded_v1.rws index 557c71d..397a20c 100644 --- a/docker/saves/rle_crashlanded_v1.rws +++ b/docker/saves/rle_crashlanded_v1.rws @@ -185,6 +185,7 @@ + Smithing
  • CataphractArmor
  • @@ -2652,7 +2653,7 @@ AAAAAA== Spring - 37771 + 37772 12 4 93 @@ -458687,6 +458688,22 @@ Hw== + + SimpleResearchBench + SimpleResearchBench37771 + 0 + (128, 0, 136) + 180 + WoodLog + Faction_11 + + 0 + -1 + True + + + + diff --git a/saves/README.md b/saves/README.md index b36222d..e7f9dbc 100644 --- a/saves/README.md +++ b/saves/README.md @@ -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: diff --git a/saves/rle_crashlanded_v1.rws.gz b/saves/rle_crashlanded_v1.rws.gz index fee6dae..4fce855 100644 Binary files a/saves/rle_crashlanded_v1.rws.gz and b/saves/rle_crashlanded_v1.rws.gz differ diff --git a/scripts/create_scenario_saves.py b/scripts/create_scenario_saves.py index 16de6c9..7e0f5e6 100644 --- a/scripts/create_scenario_saves.py +++ b/scripts/create_scenario_saves.py @@ -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 @@ -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 diff --git a/src/rle/harness/brief.py b/src/rle/harness/brief.py index 54eca5d..977c67c 100644 --- a/src/rle/harness/brief.py +++ b/src/rle/harness/brief.py @@ -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 @@ -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. @@ -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) @@ -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": { diff --git a/src/rle/orchestration/action_executor.py b/src/rle/orchestration/action_executor.py index 35a56a3..2649a3f 100644 --- a/src/rle/orchestration/action_executor.py +++ b/src/rle/orchestration/action_executor.py @@ -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." diff --git a/src/rle/orchestration/preflight.py b/src/rle/orchestration/preflight.py index ca897fb..e968bf7 100644 --- a/src/rle/orchestration/preflight.py +++ b/src/rle/orchestration/preflight.py @@ -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 """ diff --git a/src/rle/rimapi/client.py b/src/rle/rimapi/client.py index 5a396ee..d226b0d 100644 --- a/src/rle/rimapi/client.py +++ b/src/rle/rimapi/client.py @@ -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.""" @@ -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)) @@ -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)), diff --git a/src/rle/rimapi/schemas.py b/src/rle/rimapi/schemas.py index 33df605..a9ab682 100644 --- a/src/rle/rimapi/schemas.py +++ b/src/rle/rimapi/schemas.py @@ -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.""" diff --git a/src/rle/scenarios/definitions/01_crashlanded_survival.yaml b/src/rle/scenarios/definitions/01_crashlanded_survival.yaml index 438eb36..1419973 100644 --- a/src/rle/scenarios/definitions/01_crashlanded_survival.yaml +++ b/src/rle/scenarios/definitions/01_crashlanded_survival.yaml @@ -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 @@ -26,4 +26,4 @@ scoring_weights: self_sufficiency: 0.16 efficiency: 0.04 plan_coherence: 0.08 -save_sha256: 29530cd8e5f373b50f9ee51617bc0b036de97f95c9fd3264a2bbd5939a133d4e +save_sha256: 17835b77622ca633228b00f7616af2cc45198b51fbb5169adc8233370e0d4bbf diff --git a/src/rle/testing/scripted_agent.py b/src/rle/testing/scripted_agent.py index 5394611..6ce9a6f 100644 --- a/src/rle/testing/scripted_agent.py +++ b/src/rle/testing/scripted_agent.py @@ -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"}), ) diff --git a/tests/unit/test_brief.py b/tests/unit/test_brief.py index 7ac9087..6b00416 100644 --- a/tests/unit/test_brief.py +++ b/tests/unit/test_brief.py @@ -2,7 +2,12 @@ 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, @@ -10,6 +15,7 @@ MapData, ResearchData, ResourceData, + StructureData, TerrainSummary, WeatherData, ) @@ -17,7 +23,10 @@ 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), @@ -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=[], @@ -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: @@ -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() diff --git a/tests/unit/test_crashlanded_save.py b/tests/unit/test_crashlanded_save.py new file mode 100644 index 0000000..8aa33a5 --- /dev/null +++ b/tests/unit/test_crashlanded_save.py @@ -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 "SimpleResearchBench" in text + assert 'Class="Building_ResearchBench"' in text + assert "Smithing" in text + assert "(128, 0, 136)" 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 diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index 10a9f8f..7f986b6 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -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: diff --git a/tests/unit/test_preflight.py b/tests/unit/test_preflight.py index 93386e1..c7ea534 100644 --- a/tests/unit/test_preflight.py +++ b/tests/unit/test_preflight.py @@ -236,8 +236,8 @@ async def test_available_research_is_queued(self) -> None: assert result.executed == 1 client.set_research_target.assert_awaited_once_with("Smithing", force=False) - async def test_current_project_is_still_queued(self) -> None: - """Re-targeting the current project is allowed; it is available.""" + async def test_current_project_is_already_satisfied(self) -> None: + """Re-targeting the current project is success-by-state; no rewrite.""" client = AsyncMock() executor = ActionExecutor(client) result = await executor.execute( @@ -245,7 +245,8 @@ async def test_current_project_is_still_queued(self) -> None: state=_state(), ) assert result.executed == 1 - client.set_research_target.assert_awaited_once_with("Electricity", force=False) + assert result.failed == 0 + client.set_research_target.assert_not_awaited() # -- 4. Doctor + patient preflight ------------------------------------------- diff --git a/tests/unit/test_rimapi_client.py b/tests/unit/test_rimapi_client.py index 97981b7..1601e24 100644 --- a/tests/unit/test_rimapi_client.py +++ b/tests/unit/test_rimapi_client.py @@ -301,6 +301,36 @@ async def test_get_map(self, mock_client: RimAPIClient) -> None: assert isinstance(result, MapData) assert result.biome == "temperate_forest" + async def test_get_map_keeps_research_bench_when_many_buildings( + self, all_routes: dict, + ) -> None: + walls = [ + { + "id": i, + "def_name": "Wall", + "position": [i, 0], + "hit_points": 100, + "max_hit_points": 100, + } + for i in range(60) + ] + walls.append({ + "id": 37771, + "def_name": "SimpleResearchBench", + "position": [128, 136], + "hit_points": 180, + "max_hit_points": 180, + }) + routes = dict(all_routes) + routes["/api/v1/map/buildings?map_id=0"] = walls + transport = _make_transport(routes, _WRITE_ROUTES) + async with RimAPIClient("http://test") as client: + client._client = httpx.AsyncClient(transport=transport, base_url="http://test") + result = await client.get_map() + assert len(result.structures) == 50 + assert result.structures[0].def_name == "SimpleResearchBench" + assert result.structures[0].position == (128, 136) + async def test_get_research(self, mock_client: RimAPIClient) -> None: result = await mock_client.get_research() assert isinstance(result, ResearchData) diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index 579faab..c26eea1 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -15,6 +15,7 @@ StructureData, ThreatData, WeatherData, + is_research_bench_def, ) @@ -92,6 +93,13 @@ def test_valid_construction(self, sample_structure: StructureData) -> None: assert sample_structure.def_name == "Wall" assert sample_structure.hit_points == 300.0 + def test_research_bench_def_names(self) -> None: + assert is_research_bench_def("SimpleResearchBench") + assert is_research_bench_def("HiTechResearchBench") + assert is_research_bench_def("research_bench") + assert not is_research_bench_def("Wall") + assert not is_research_bench_def("Table2x2c") + class TestMapData: def test_valid_construction(self, sample_map: MapData) -> None: