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
20 changes: 20 additions & 0 deletions scripts/run_scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from rle.agents.social_overseer import SocialOverseer
from rle.config import RLEConfig, bridge_anthropic_key, bridge_openrouter_key
from rle.docker import wait_for_rimapi
from rle.orchestration.camera_director import CameraDirector
from rle.orchestration.game_loop import RLEGameLoop
from rle.rimapi.client import RimAPIClient
from rle.rimapi.sse_client import RimAPISSEClient
Expand Down Expand Up @@ -276,6 +277,13 @@ async def main(args: argparse.Namespace) -> None:
except Exception as e:
print(f"Setup command {cmd.type} failed: {e}")

camera_director = None
if args.camera_director:
camera_director = CameraDirector(
client,
output_dir=Path(args.output) if args.output else None,
)

loop = RLEGameLoop(
config, client, agents,
expected_duration_days=scenario.expected_duration_days,
Expand All @@ -292,6 +300,8 @@ async def main(args: argparse.Namespace) -> None:
event_log=event_log,
cost_tracker=cost_tracker,
triggered_incidents=scenario.triggered_incidents,
auto_dismiss_dialogs=not args.no_dismiss_dialogs,
camera_director=camera_director,
)
try:
if visualizer:
Expand Down Expand Up @@ -413,6 +423,16 @@ async def main(args: argparse.Namespace) -> None:
"--no-pause", action="store_true",
help="Don't pause game during deliberation (SSE-driven, game runs continuously)",
)
parser.add_argument(
"--camera-director", action="store_true",
help="Drive the game camera to the action each tick for cinematic "
"capture (writes camera_cues.jsonl to --output). Issue #34.",
)
parser.add_argument(
"--no-dismiss-dialogs", action="store_true",
help="Don't auto-dismiss force-pause popups (colony-name dialog, debug "
"log). Dismissal is on by default for unattended runs. Issue #33.",
)
parser.add_argument(
"--seed", type=int, default=None,
help=(
Expand Down
45 changes: 43 additions & 2 deletions src/rle/agents/base_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@
"- work_priority for EVERY colonist: best Plants→Growing=1, "
"best Construction→Construction=1, best Intellectual→Research=1\n"
"- growing_zone: use FARM SITE coordinates from MAP_SUMMARY, "
"plant_def=Plant_Rice (fastest food)\n\n"
"plant_def=Plant_Rice (fastest food). Create this zone ONCE — if a "
"growing zone already exists, do NOT create another (it will fail).\n\n"
"TICK 2 (SHELTER — most critical):\n"
"- blueprint Wall: place 5x5 rectangle using SHELTER SITE from MAP_SUMMARY. "
"Use stuff_def=WoodLog. Leave one gap for a Door.\n"
Expand Down Expand Up @@ -247,9 +248,49 @@ def _build_messages(self, system_prompt: str, user_prompt: str) -> list[ChatMess

def _record_completion(self, result: CompletionResult) -> CompletionResult:
self._last_raw_output = result.content
self._last_usage = result.usage if hasattr(result, "usage") else None
usage = dict(result.usage) if getattr(result, "usage", None) else None
if usage is not None:
# felix's usage dict only carries prompt/completion/total. Thinking
# models bill hidden reasoning tokens that OpenRouter/OpenAI report
# in usage.completion_tokens_details.reasoning_tokens but leave OUT
# of completion_tokens — dig them out of the raw response so the
# cost tracker can bill them (issue #33).
reasoning = self._extract_reasoning_tokens(getattr(result, "raw_response", None))
if reasoning:
usage["reasoning_tokens"] = reasoning
self._last_usage = usage
return result

@staticmethod
def _extract_reasoning_tokens(raw_response: Any) -> int:
"""Pull reasoning-token count from a provider's raw usage payload.

Handles both the OpenAI SDK object shape
(``usage.completion_tokens_details.reasoning_tokens``) and the plain
dict shape returned by OpenRouter / OpenAI-compatible servers. Returns
0 when absent or unparseable — never raises.
"""
if raw_response is None:
return 0

def _get(obj: Any, key: str) -> Any:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)

usage = _get(raw_response, "usage")
if usage is None:
return 0
details = _get(usage, "completion_tokens_details")
# Some servers attach reasoning_tokens directly on usage.
candidate = _get(details, "reasoning_tokens") if details is not None else None
if candidate is None:
candidate = _get(usage, "reasoning_tokens")
try:
return int(candidate) if candidate is not None else 0
except (TypeError, ValueError):
return 0

def _call_provider(
self,
system_prompt: str,
Expand Down
32 changes: 28 additions & 4 deletions src/rle/orchestration/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ class ActionExecutor:

def __init__(self, client: RimAPIClient) -> None:
self._client = client
# Rectangles (x1, z1, x2, z2) of growing zones already created this run.
# Agents perseverate — they re-issue the same growing zone every tick.
# Each repeat targets cells already owned by the first zone, which RIMAPI
# rejects (and historically mislabelled as "Invalid plant definition").
# We short-circuit overlapping repeats with an explicit error so the
# agent learns the zone exists instead of burning ticks on doomed calls
# (issue #33).
self._created_growing_zones: list[tuple[int, int, int, int]] = []

@staticmethod
def _rects_overlap(
a: tuple[int, int, int, int], b: tuple[int, int, int, int]
) -> bool:
ax1, az1, ax2, az2 = a
bx1, bz1, bx2, bz2 = b
return not (ax2 < bx1 or ax1 > bx2 or az2 < bz1 or az1 > bz2)

async def execute(self, plan: ActionPlan) -> ExecutionResult:
"""Execute all actions in a plan, return summary + per-action outcomes."""
Expand Down Expand Up @@ -232,10 +248,17 @@ async def _h_blueprint(self, cid: str, params: dict[str, Any]) -> None:
)

async def _h_growing_zone(self, cid: str, params: dict[str, Any]) -> None:
x1 = params.get("x1", params.get("x", 0))
z1 = params.get("z1", params.get("z", 0))
x2 = params.get("x2", x1 + 5)
z2 = params.get("z2", z1 + 5)
x1 = int(params.get("x1", params.get("x", 0)))
z1 = int(params.get("z1", params.get("z", 0)))
x2 = int(params.get("x2", x1 + 5))
z2 = int(params.get("z2", z1 + 5))
rect = (min(x1, x2), min(z1, z2), max(x1, x2), max(z1, z2))
if any(self._rects_overlap(rect, prev) for prev in self._created_growing_zones):
raise ValueError(
"A growing zone already covers these cells — it was created "
"earlier this run. Do NOT recreate it; pick a different, "
"non-overlapping rectangle or move on to another task."
)
await self._client.create_growing_zone(
map_id=int(params.get("map_id", 0)),
plant_def=params.get("plant_def", "Plant_Potato"),
Expand All @@ -244,6 +267,7 @@ async def _h_growing_zone(self, cid: str, params: dict[str, Any]) -> None:
x2=x2,
z2=z2,
)
self._created_growing_zones.append(rect)

async def _h_stockpile_zone(self, cid: str, params: dict[str, Any]) -> None:
x1 = params.get("x1", params.get("x", 0))
Expand Down
Loading
Loading