1313 ExecutionResult ,
1414 resolve_endpoint ,
1515)
16+ from rle .orchestration .preflight import (
17+ VANILLA_WORK_TYPES ,
18+ growing_zone_already_covers ,
19+ is_already_covered_error ,
20+ is_quarantined_write ,
21+ normalize_work_priorities ,
22+ research_target_status ,
23+ resolve_tend_pair ,
24+ )
1625from rle .rimapi .api_catalog import WRITE_CATALOG
17- from rle .rimapi .client import RimAPIClient , RimAPIResponseError
26+ from rle .rimapi .client import RimAPIClient , RimAPIConnectionError , RimAPIResponseError
27+ from rle .rimapi .schemas import GameState
1828
1929__all__ = ["NEEDS_PAWN" , "ActionExecutor" , "ActionOutcome" , "ExecutionResult" ]
2030
@@ -47,31 +57,6 @@ def _extract_rimapi_error(detail: str) -> str:
4757 return detail
4858
4959
50- def _normalize_work_priorities (params : dict [str , Any ]) -> dict [str , int ]:
51- """Accept the parameter shapes models actually emit for work_priority.
52-
53- Documented shape is flat ``{"<WorkType>": <1-4>}``, but frontier models
54- also emit ``{"work_type": "Research", "priority": 2}`` and
55- ``{"work_priorities": {"Growing": 1, ...}}`` (issue #27). Passing those
56- through verbatim posted garbage like work="work_type" to RIMAPI.
57- """
58- nested = params .get ("work_priorities" )
59- if isinstance (nested , dict ):
60- return {str (work ): int (pri ) for work , pri in nested .items ()}
61- if "work_type" in params :
62- return {str (params ["work_type" ]): int (params .get ("priority" , 1 ))}
63- flat = {
64- str (work ): int (pri ) for work , pri in params .items ()
65- if isinstance (pri , int ) and not isinstance (pri , bool )
66- }
67- if not flat :
68- raise ValueError (
69- 'work_priority requires {"<WorkType>": <1-4>} parameters '
70- "(e.g. {\" Growing\" : 1})"
71- )
72- return flat
73-
74-
7560class ActionExecutor :
7661 """Dispatches agent actions to RIMAPI write endpoints.
7762
@@ -85,15 +70,15 @@ def __init__(self, client: RimAPIClient) -> None:
8570 # Agents perseverate — they re-issue the same growing zone every tick.
8671 # Each repeat targets cells already owned by the first zone, which RIMAPI
8772 # rejects (and historically mislabelled as "Invalid plant definition").
88- # We short-circuit overlapping repeats with an explicit error so the
89- # agent learns the zone exists instead of burning ticks on doomed calls
90- # (issue #33).
73+ # Overlapping repeats are success-by-state (the farm already exists).
9174 self ._created_growing_zones : list [tuple [int , int , int , int ]] = []
9275 # Same guard for stockpiles: RimWorld logs one "overwriting slot group
9376 # square" error PER CELL when a stockpile overlaps an existing one,
9477 # which spams the dev log (and pops it over the game when auto-open
9578 # is enabled).
9679 self ._created_stockpile_zones : list [tuple [int , int , int , int ]] = []
80+ self ._tick_state : GameState | None = None
81+ self ._work_types : frozenset [str ] | None = None
9782
9883 @staticmethod
9984 def _rects_overlap (
@@ -103,8 +88,16 @@ def _rects_overlap(
10388 bx1 , bz1 , bx2 , bz2 = b
10489 return not (ax2 < bx1 or ax1 > bx2 or az2 < bz1 or az1 > bz2 )
10590
106- async def execute (self , plan : ActionPlan ) -> ExecutionResult :
107- """Execute all actions in a plan, return summary + per-action outcomes."""
91+ async def execute (
92+ self , plan : ActionPlan , state : GameState | None = None ,
93+ ) -> ExecutionResult :
94+ """Execute all actions in a plan, return summary + per-action outcomes.
95+
96+ ``state`` is the current colony snapshot used by preflight gates
97+ (research availability, living doctor/patient). Harnesses that omit
98+ it keep the write path; gates that need state are skipped.
99+ """
100+ self ._tick_state = state
108101 executed = 0
109102 failed = 0
110103 no_action_count = 0
@@ -158,6 +151,15 @@ async def _dispatch(self, action: Action, endpoint: str) -> None:
158151 cid = action .target_colonist_id or ""
159152 params = action .parameters
160153
154+ if is_quarantined_write (endpoint ):
155+ raise ValueError (
156+ f"{ endpoint } is quarantined — the endpoint is missing on "
157+ "this RIMAPI build. Do not advertise or call it."
158+ )
159+
160+ if endpoint == "tend" and (not cid or cid == "0" ):
161+ cid = str (params .get ("patient_pawn_id" ) or params .get ("patient_id" ) or "" )
162+
161163 # Skip pawn-targeting actions with no valid colonist ID
162164 if endpoint in _NEEDS_PAWN and (not cid or cid == "0" ):
163165 logger .info ("Skipping %s: no valid colonist ID" , endpoint )
@@ -180,9 +182,22 @@ async def _dispatch(self, action: Action, endpoint: str) -> None:
180182
181183 # -- Specialized handlers (parameter mapping for complex DTOs) -----------
182184
185+ async def _allowed_work_types (self ) -> frozenset [str ]:
186+ if self ._work_types is not None :
187+ return self ._work_types
188+ try :
189+ names = await self ._client .get_work_list ()
190+ except (RimAPIResponseError , RimAPIConnectionError , TypeError , AttributeError ):
191+ names = None
192+ if isinstance (names , list ) and names :
193+ self ._work_types = frozenset (str (item ) for item in names )
194+ else :
195+ self ._work_types = VANILLA_WORK_TYPES
196+ return self ._work_types
197+
183198 async def _h_work_priority (self , cid : str , params : dict [str , Any ]) -> None :
184199 await self ._client .set_work_priorities (
185- cid , _normalize_work_priorities (params ),
200+ cid , normalize_work_priorities (params , await self . _allowed_work_types () ),
186201 )
187202
188203 async def _h_draft (self , cid : str , params : dict [str , Any ]) -> None :
@@ -219,7 +234,8 @@ async def _h_bed_rest(self, cid: str, params: dict[str, Any]) -> None:
219234 await self ._client .assign_bed_rest (cid , bed_building_id = params .get ("bed_building_id" ))
220235
221236 async def _h_tend (self , cid : str , params : dict [str , Any ]) -> None :
222- await self ._client .administer_medicine (cid , doctor_id = params .get ("doctor_id" ))
237+ doctor_id , patient_id = resolve_tend_pair (cid , params , self ._tick_state )
238+ await self ._client .administer_medicine (patient_id , doctor_id = doctor_id )
223239
224240 async def _h_blueprint (self , cid : str , params : dict [str , Any ]) -> None :
225241 if "x" not in params or "z" not in params :
@@ -240,22 +256,39 @@ async def _h_growing_zone(self, cid: str, params: dict[str, Any]) -> None:
240256 x2 = int (params .get ("x2" , x1 + 5 ))
241257 z2 = int (params .get ("z2" , z1 + 5 ))
242258 rect = (min (x1 , x2 ), min (z1 , z2 ), max (x1 , x2 ), max (z1 , z2 ))
259+ map_id = int (params .get ("map_id" , 0 ))
243260 if any (self ._rects_overlap (rect , prev ) for prev in self ._created_growing_zones ):
244- raise ValueError (
245- "A growing zone already covers these cells — it was created "
246- "earlier this run. Do NOT recreate it; pick a different, "
247- "non-overlapping rectangle or move on to another task."
261+ # Already satisfied this run — do not recreate or score as failure.
262+ return
263+ if await self ._growing_zone_covered_on_map (map_id , x1 , z1 , x2 , z2 ):
264+ self ._created_growing_zones .append (rect )
265+ return
266+ try :
267+ await self ._client .create_growing_zone (
268+ map_id = map_id ,
269+ plant_def = params .get ("plant_def" , "Plant_Potato" ),
270+ x1 = x1 ,
271+ z1 = z1 ,
272+ x2 = x2 ,
273+ z2 = z2 ,
248274 )
249- await self ._client .create_growing_zone (
250- map_id = int (params .get ("map_id" , 0 )),
251- plant_def = params .get ("plant_def" , "Plant_Potato" ),
252- x1 = x1 ,
253- z1 = z1 ,
254- x2 = x2 ,
255- z2 = z2 ,
256- )
275+ except RimAPIResponseError as exc :
276+ if is_already_covered_error (_extract_rimapi_error (exc .detail )):
277+ self ._created_growing_zones .append (rect )
278+ return
279+ raise
257280 self ._created_growing_zones .append (rect )
258281
282+ async def _growing_zone_covered_on_map (
283+ self , map_id : int , x1 : int , z1 : int , x2 : int , z2 : int ,
284+ ) -> bool :
285+ """Ask ``POST /builder/check-zone`` whether a zone already owns these cells."""
286+ try :
287+ payload = await self ._client .check_zone (map_id , x1 , z1 , x2 , z2 )
288+ except (RimAPIResponseError , RimAPIConnectionError , AttributeError , TypeError ):
289+ return False
290+ return growing_zone_already_covers (payload )
291+
259292 # RimWorld's StoragePriority is semantic; models reasonably emit the
260293 # words. Map them instead of crashing in int() (found in the 2026-06-11
261294 # spread: fable5 sent priority="important").
@@ -324,8 +357,22 @@ async def _h_toggle_power(self, cid: str, params: dict[str, Any]) -> None:
324357 )
325358
326359 async def _h_research_target (self , cid : str , params : dict [str , Any ]) -> None :
327- project = params .get ("project" , params .get ("name" , "" ))
328- await self ._client .set_research_target (project , force = params .get ("force" , False ))
360+ project = str (params .get ("project" , params .get ("name" , "" )))
361+ force = bool (params .get ("force" , False ))
362+ research = None if self ._tick_state is None else self ._tick_state .research
363+ if research is not None and not force :
364+ status = research_target_status (project , research )
365+ if status == "finished" :
366+ raise ValueError (
367+ f"Research project '{ project } ' is already finished."
368+ )
369+ if status == "locked" :
370+ raise ValueError (
371+ f"Research project '{ project } ' is not currently available "
372+ "(prerequisites or research bench missing). Only queue "
373+ "projects listed in research.available."
374+ )
375+ await self ._client .set_research_target (project , force = force )
329376
330377 async def _h_research_stop (self , cid : str , params : dict [str , Any ]) -> None :
331378 await self ._client .stop_research ()
0 commit comments