From 045e884184c5bfaf8cae695e74db3be8b2e8205a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:36:03 +0000 Subject: [PATCH 1/2] fix: read BuildingDto.def and merge research progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-#76 native pin-match still reported research_bench_present false and research_target Smithing locked. Live buildings use snake_case def (label fallback has spaces); /research/summary has no current project and by-tech-level bucketing dropped Smithing from available. Map def → def_name → label, strip spaces in the bench matcher, and merge /research/progress + finished + tree so current can be Smithing. Co-authored-by: Jason --- src/rle/rimapi/client.py | 138 ++++++++++++++++++++++++++----- src/rle/rimapi/schemas.py | 7 +- tests/unit/test_rimapi_client.py | 109 ++++++++++++++++++++++++ tests/unit/test_schemas.py | 2 + 4 files changed, 234 insertions(+), 22 deletions(-) diff --git a/src/rle/rimapi/client.py b/src/rle/rimapi/client.py index d226b0d..713e7c4 100644 --- a/src/rle/rimapi/client.py +++ b/src/rle/rimapi/client.py @@ -42,7 +42,59 @@ def _building_def_name(building: dict[str, Any]) -> str: - return str(building.get("def_name", building.get("label", "Unknown"))) + """RIMAPI BuildingDto serializes the defName as snake_case ``def``. + + Fall back to ``def_name`` (mocks / older shapes) then ``label``. + """ + for key in ("def", "def_name", "label"): + if key in building and building[key] not in (None, ""): + return str(building[key]) + return "Unknown" + + +def _research_project_name(item: Any) -> str: + if isinstance(item, str) and item: + return item + if isinstance(item, dict): + for key in ("name", "def_name", "label"): + if key in item and item[key] not in (None, ""): + return str(item[key]) + return "" + + +def _finished_project_names(finished: Any) -> list[str]: + if isinstance(finished, list): + return [str(item) for item in finished if item] + if not isinstance(finished, dict): + return [] + raw = finished.get("finished_projects", finished.get("FinishedProjects", [])) + if not isinstance(raw, list): + return [] + return [str(item) for item in raw if item] + + +def _tree_completed_and_available(tree: Any) -> tuple[list[str], list[str]]: + """Split ``/research/tree`` into finished vs ``can_start_now`` names.""" + if isinstance(tree, list): + projects = tree + elif isinstance(tree, dict): + raw = tree.get("projects", tree.get("Projects", [])) + projects = raw if isinstance(raw, list) else [] + else: + return [], [] + completed: list[str] = [] + available: list[str] = [] + for item in projects: + if not isinstance(item, dict): + continue + name = _research_project_name(item) + if not name: + continue + if item.get("is_finished", item.get("IsFinished", False)): + completed.append(name) + elif item.get("can_start_now", item.get("CanStartNow", False)): + available.append(name) + return completed, available def _select_buildings_for_state(buildings: Any) -> list[Any]: @@ -160,6 +212,13 @@ async def _get(self, path: str) -> Any: raise RimAPIResponseError(resp.status_code, resp.text) return self._unwrap_envelope(resp.json()) + async def _optional_get(self, path: str) -> Any: + """GET that returns None when the endpoint is missing or unreachable.""" + try: + return await self._get(path) + except (RimAPIResponseError, RimAPIConnectionError): + return None + async def call( self, method: str, path: str, json: dict[str, Any] | None = None, ) -> Any: @@ -288,24 +347,58 @@ def _adapt_colony(raw: dict[str, Any]) -> dict[str, Any]: } @staticmethod - def _adapt_research(raw: dict[str, Any]) -> dict[str, Any]: - """Map upstream ResearchSummaryDto → ResearchData fields.""" + def _adapt_research( + raw: dict[str, Any], + *, + progress: Any = None, + finished: Any = None, + tree: Any = None, + ) -> dict[str, Any]: + """Map RIMAPI research endpoints → ResearchData fields. + + ``/research/summary`` has counts and by-tech-level project bags, not + ``current_project``. Treating a tech level with ``finished > 0`` as + entirely completed dumps Medieval (incl. Smithing) into + ``completed``, then ``[:finished_projects_count]`` drops it from + ``available`` — the validator then reports ``locked``. + + Overlay ``/research/progress`` for the queued project and + ``/research/finished`` + ``/research/tree`` for honest lists. + """ + completed_raw = raw.get("completed") + available_raw = raw.get("available") if "current_project" in raw: - return raw - completed = [] - available = [] - for _level, cat in raw.get("by_tech_level", {}).items(): - for proj in cat.get("projects", []): - if cat.get("finished", 0) > 0: - completed.append(proj) - else: - available.append(proj) - return { - "current_project": None, - "progress": 0.0, - "completed": completed[:raw.get("finished_projects_count", 0)], - "available": available, - } + adapted: dict[str, Any] = { + "current_project": raw.get("current_project"), + "progress": float(raw.get("progress", 0.0) or 0.0), + "completed": list(completed_raw) if isinstance(completed_raw, list) else [], + "available": list(available_raw) if isinstance(available_raw, list) else [], + } + else: + adapted = { + "current_project": None, + "progress": 0.0, + "completed": [], + "available": [], + } + + finished_names = _finished_project_names(finished) + tree_completed, tree_available = _tree_completed_and_available(tree) + if finished_names: + adapted["completed"] = finished_names + elif tree is not None: + adapted["completed"] = tree_completed + if tree is not None: + adapted["available"] = tree_available + + if isinstance(progress, dict): + name = progress.get("name", progress.get("current_project")) + if name: + adapted["current_project"] = str(name) + if progress.get("progress_percent") is not None: + adapted["progress"] = float(progress["progress_percent"]) + + return adapted # ------------------------------------------------------------------ # Read endpoints @@ -912,7 +1005,14 @@ def _find_clear_rect( async def get_research(self) -> ResearchData: data = await self._get("/api/v1/research/summary") - return ResearchData.model_validate(self._adapt_research(data)) + if not isinstance(data, dict): + data = {} + progress = await self._optional_get("/api/v1/research/progress") + finished = await self._optional_get("/api/v1/research/finished") + tree = await self._optional_get("/api/v1/research/tree") + return ResearchData.model_validate( + self._adapt_research(data, progress=progress, finished=finished, tree=tree), + ) async def get_work_list(self) -> list[str]: """WorkTypeDef defNames from ``GET /api/v1/work-list``.""" diff --git a/src/rle/rimapi/schemas.py b/src/rle/rimapi/schemas.py index a9ab682..87f2636 100644 --- a/src/rle/rimapi/schemas.py +++ b/src/rle/rimapi/schemas.py @@ -21,10 +21,11 @@ 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. + Matching is case-insensitive and ignores underscores and spaces so + ``Research_Bench`` and ``simple research bench`` still count. """ - return "researchbench" in def_name.lower().replace("_", "") + normalized = def_name.lower().replace("_", "").replace(" ", "") + return "researchbench" in normalized class ColonistData(BaseModel): diff --git a/tests/unit/test_rimapi_client.py b/tests/unit/test_rimapi_client.py index 1601e24..870545b 100644 --- a/tests/unit/test_rimapi_client.py +++ b/tests/unit/test_rimapi_client.py @@ -8,10 +8,13 @@ import httpx import pytest +from rle.orchestration.preflight import research_target_status from rle.rimapi.client import ( RimAPIClient, RimAPIConnectionError, RimAPIResponseError, + _building_def_name, + _select_buildings_for_state, ) from rle.rimapi.schemas import ( AlertData, @@ -26,6 +29,7 @@ ScreenshotResponse, ThreatData, WeatherData, + is_research_bench_def, ) # ------------------------------------------------------------------ @@ -335,6 +339,111 @@ async def test_get_research(self, mock_client: RimAPIClient) -> None: result = await mock_client.get_research() assert isinstance(result, ResearchData) + +class TestResearchUnblockAdapters: + """Post-#76 RCA: live BuildingDto ``def`` + research progress merge.""" + + def test_building_def_field_marks_bench(self) -> None: + name = _building_def_name({ + "def": "SimpleResearchBench", + "label": "simple research bench", + }) + assert name == "SimpleResearchBench" + assert is_research_bench_def(name) + selected = _select_buildings_for_state([ + {"def": "Wall", "label": "wall"}, + {"def": "SimpleResearchBench", "label": "simple research bench"}, + ]) + assert _building_def_name(selected[0]) == "SimpleResearchBench" + + async def test_get_map_reads_live_def_field(self, all_routes: dict) -> None: + walls = [ + {"id": i, "def": "Wall", "label": "wall", "position": [i, 0]} + for i in range(60) + ] + walls.append({ + "id": 37771, + "def": "SimpleResearchBench", + "label": "simple research bench", + "position": {"x": 128, "y": 0, "z": 136}, + }) + 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 result.structures[0].def_name == "SimpleResearchBench" + assert is_research_bench_def(result.structures[0].def_name) + + def test_spacey_label_matches_research_bench(self) -> None: + assert is_research_bench_def("simple research bench") + assert is_research_bench_def(_building_def_name({"label": "simple research bench"})) + selected = _select_buildings_for_state([ + {"label": "wall"}, + {"label": "simple research bench"}, + ]) + assert is_research_bench_def(_building_def_name(selected[0])) + + async def test_progress_merge_exposes_current_smithing( + self, all_routes: dict, + ) -> None: + routes = dict(all_routes) + # Live summary has no current_project. Medieval finished>0 used to + # dump Smithing into completed, then truncate it out of available. + routes["/api/v1/research/summary"] = { + "finished_projects_count": 2, + "total_projects_count": 6, + "available_projects_count": 2, + "by_tech_level": { + "Neolithic": { + "finished": 1, + "total": 2, + "projects": ["PsychoidBrewing", "Devouring"], + }, + "Medieval": { + "finished": 1, + "total": 3, + "projects": ["Stonecutting", "Smithing", "ComplexClothing"], + }, + }, + } + routes["/api/v1/research/progress"] = { + "name": "Smithing", + "label": "smithing", + "progress": 0, + "research_points": 700, + "is_finished": False, + "can_start_now": True, + "progress_percent": 0.0, + } + routes["/api/v1/research/finished"] = { + "finished_projects": ["PsychoidBrewing", "Stonecutting"], + } + routes["/api/v1/research/tree"] = { + "projects": [ + {"name": "PsychoidBrewing", "is_finished": True, "can_start_now": False}, + {"name": "Stonecutting", "is_finished": True, "can_start_now": False}, + {"name": "Smithing", "is_finished": False, "can_start_now": True}, + {"name": "ComplexClothing", "is_finished": False, "can_start_now": True}, + {"name": "Fabrication", "is_finished": False, "can_start_now": False}, + {"name": "Devouring", "is_finished": False, "can_start_now": False}, + ], + } + 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_research() + assert result.current_project == "Smithing" + assert "Smithing" not in result.completed + assert result.completed == ["PsychoidBrewing", "Stonecutting"] + assert "Smithing" in result.available + assert "ComplexClothing" in result.available + assert "Fabrication" not in result.available + assert research_target_status("Smithing", result) == "current" + assert research_target_status("ComplexClothing", result) == "available" + assert research_target_status("Fabrication", result) == "locked" + async def test_get_threats(self, mock_client: RimAPIClient) -> None: result = await mock_client.get_threats() assert len(result) == 1 diff --git a/tests/unit/test_schemas.py b/tests/unit/test_schemas.py index c26eea1..bd021e0 100644 --- a/tests/unit/test_schemas.py +++ b/tests/unit/test_schemas.py @@ -97,6 +97,8 @@ 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 is_research_bench_def("simple research bench") + assert is_research_bench_def("Simple Research Bench") assert not is_research_bench_def("Wall") assert not is_research_bench_def("Table2x2c") From 84660a4f691a09b859df489419b487f4cb691b87 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 18:37:54 +0000 Subject: [PATCH 2/2] test: keep research adapter cases out of TestReadEndpoints Move the post-#76 adapter tests into their own class so the existing read-endpoint cases stay under TestReadEndpoints. Co-authored-by: Jason --- tests/unit/test_rimapi_client.py | 52 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/tests/unit/test_rimapi_client.py b/tests/unit/test_rimapi_client.py index 870545b..50bce08 100644 --- a/tests/unit/test_rimapi_client.py +++ b/tests/unit/test_rimapi_client.py @@ -339,6 +339,32 @@ async def test_get_research(self, mock_client: RimAPIClient) -> None: result = await mock_client.get_research() assert isinstance(result, ResearchData) + async def test_get_threats(self, mock_client: RimAPIClient) -> None: + result = await mock_client.get_threats() + assert len(result) == 1 + assert isinstance(result[0], ThreatData) + + async def test_get_colony(self, mock_client: RimAPIClient) -> None: + result = await mock_client.get_colony() + assert isinstance(result, ColonyData) + assert result.name == "New Hope" + + async def test_get_weather(self, mock_client: RimAPIClient) -> None: + result = await mock_client.get_weather() + assert isinstance(result, WeatherData) + + async def test_get_game_state(self, mock_client: RimAPIClient) -> None: + result = await mock_client.get_game_state() + assert isinstance(result, GameState) + assert result.colony.name == "New Hope" + assert len(result.colonists) == 1 + assert result.timestamp > 0 + # Phase 1: power and factions included in game state + assert result.power is not None + assert result.power.current_power == 1800.0 + assert len(result.factions) == 2 + assert result.factions[0].name == "Pirate Band" + class TestResearchUnblockAdapters: """Post-#76 RCA: live BuildingDto ``def`` + research progress merge.""" @@ -444,32 +470,6 @@ async def test_progress_merge_exposes_current_smithing( assert research_target_status("ComplexClothing", result) == "available" assert research_target_status("Fabrication", result) == "locked" - async def test_get_threats(self, mock_client: RimAPIClient) -> None: - result = await mock_client.get_threats() - assert len(result) == 1 - assert isinstance(result[0], ThreatData) - - async def test_get_colony(self, mock_client: RimAPIClient) -> None: - result = await mock_client.get_colony() - assert isinstance(result, ColonyData) - assert result.name == "New Hope" - - async def test_get_weather(self, mock_client: RimAPIClient) -> None: - result = await mock_client.get_weather() - assert isinstance(result, WeatherData) - - async def test_get_game_state(self, mock_client: RimAPIClient) -> None: - result = await mock_client.get_game_state() - assert isinstance(result, GameState) - assert result.colony.name == "New Hope" - assert len(result.colonists) == 1 - assert result.timestamp > 0 - # Phase 1: power and factions included in game state - assert result.power is not None - assert result.power.current_power == 1800.0 - assert len(result.factions) == 2 - assert result.factions[0].name == "Pirate Band" - class TestErrorHandling: async def test_404_raises_response_error(self) -> None: