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
138 changes: 119 additions & 19 deletions src/rle/rimapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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``."""
Expand Down
7 changes: 4 additions & 3 deletions src/rle/rimapi/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
109 changes: 109 additions & 0 deletions tests/unit/test_rimapi_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +29,7 @@
ScreenshotResponse,
ThreatData,
WeatherData,
is_research_bench_def,
)

# ------------------------------------------------------------------
Expand Down Expand Up @@ -362,6 +366,111 @@ async def test_get_game_state(self, mock_client: RimAPIClient) -> None:
assert result.factions[0].name == "Pirate Band"


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"


class TestErrorHandling:
async def test_404_raises_response_error(self) -> None:
transport = _make_transport({})
Expand Down
2 changes: 2 additions & 0 deletions tests/unit/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading