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
8 changes: 5 additions & 3 deletions agent_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from dataclasses import dataclass, field
from enum import Enum

from memory_reader import ROUTE_1


class GamePhase(Enum):
"""Different phases of the game."""
Expand Down Expand Up @@ -249,7 +251,7 @@ def suggest_action_for_goal(
# early is a dead end -- runs reached the table row and
# stalled there. Leave and go north until Route 1 has
# been seen; only then approach the table.
if 0x0B not in self.visited_maps:
if ROUTE_1 not in self.visited_maps:
return "DOWN" # Exit the lab (door is at the south)
if y > 4:
return "UP"
Expand All @@ -272,7 +274,7 @@ def suggest_action_for_goal(
return "RIGHT" # Try different direction
return "UP"
return "UP"
elif map_id == 0x0B: # Route 1
elif map_id == ROUTE_1: # Route 1
# Continue north toward Viridian
if pos and len(pos) >= 2:
if self.is_at_boundary(pos, "UP", map_id):
Expand Down Expand Up @@ -506,7 +508,7 @@ def check_goal_completion(
if "explore_route_1" not in self.completed_goals:
map_info = memory_data.get("current_map", {})
map_id = map_info.get("map_id", 0)
if map_id == 0x0B: # Route 1
if map_id == ROUTE_1: # Route 1
self.mark_goal_complete("explore_route_1")

def get_progress_summary(self) -> dict:
Expand Down
4 changes: 3 additions & 1 deletion game_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,8 +706,10 @@ def execute_action(self, action: str) -> bool:
try:
# WAIT N is in ticks of 15 frames (~0.25s of game time each);
# raw single-frame waits were far too short to let loading
# screens and transitions play out
# screens and transitions play out. Cap N so a model reply
# of WAIT 999999 cannot stall the emulator.
units = int(action.split()[1]) if len(action.split()) > 1 else 10
units = max(0, min(units, 30))
for _ in range(units * 15):
self.pyboy.tick()
return True
Expand Down
69 changes: 38 additions & 31 deletions memory_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,17 @@ class MemoryAddresses:
PARTY_POKEMON_START = 0xD16B # First Pokemon data structure
POKEMON_DATA_SIZE = 44 # Size of each Pokemon data structure

# Pokemon data structure offsets (relative to party Pokemon start)
# Pokemon data structure offsets (relative to party Pokemon start).
# pret pokered party_struct: species, current HP, box level, status,
# types, ... level at 33, max HP at 34. Offset 3 is box level, not max HP.
POKEMON_SPECIES = 0 # Pokemon species ID
POKEMON_HP_CURRENT = 1 # Current HP (2 bytes, little-endian)
POKEMON_HP_MAX = 3 # Max HP (2 bytes, little-endian)
POKEMON_STATUS = 5 # Status condition (0x00 = normal)
POKEMON_TYPE1 = 6
POKEMON_TYPE2 = 7
POKEMON_STATUS = 4 # Status condition (0x00 = normal)
POKEMON_TYPE1 = 5
POKEMON_TYPE2 = 6
POKEMON_EXP = 14 # Experience (3 bytes)
POKEMON_LEVEL = 33 # Level
POKEMON_EXP = 34 # Experience (3 bytes)
POKEMON_HP_MAX = 34 # Max HP (2 bytes, little-endian)

# Inventory
INVENTORY_COUNT = 0xD31D # Number of items in inventory
Expand Down Expand Up @@ -386,33 +388,38 @@ def read_full_game_state(self) -> dict[str, any]:
0x08: "Cinnabar Island",
0x09: "Indigo Plateau",
0x0A: "Saffron City",
0x0B: "Route 1",
0x0C: "Route 2",
0x0D: "Route 3",
0x0E: "Route 4",
0x0F: "Route 5",
0x10: "Route 6",
0x11: "Route 7",
0x12: "Route 8",
0x13: "Route 9",
0x14: "Route 10",
0x15: "Route 11",
0x16: "Route 12",
0x17: "Route 13",
0x18: "Route 14",
0x19: "Route 15",
0x1A: "Route 16",
0x1B: "Route 17",
0x1C: "Route 18",
0x1D: "Route 19",
0x1E: "Route 20",
0x1F: "Route 21",
0x20: "Route 22",
0x21: "Route 23",
0x22: "Route 24",
0x23: "Route 25",
# 0x0B is UNUSED_MAP_0B in pret pokered map_constants.asm.
# Route 1 is 0x0C; Route 25 is 0x24.
0x0C: "Route 1",
0x0D: "Route 2",
0x0E: "Route 3",
0x0F: "Route 4",
0x10: "Route 5",
0x11: "Route 6",
0x12: "Route 7",
0x13: "Route 8",
0x14: "Route 9",
0x15: "Route 10",
0x16: "Route 11",
0x17: "Route 12",
0x18: "Route 13",
0x19: "Route 14",
0x1A: "Route 15",
0x1B: "Route 16",
0x1C: "Route 17",
0x1D: "Route 18",
0x1E: "Route 19",
0x1F: "Route 20",
0x20: "Route 21",
0x21: "Route 22",
0x22: "Route 23",
0x23: "Route 24",
0x24: "Route 25",
}

# pret pokered: Route 1 is map 0x0C (0x0B is unused).
ROUTE_1 = 0x0C


def get_map_name(map_id: int) -> str:
"""Get map name from map ID.
Expand Down
35 changes: 17 additions & 18 deletions ocr_enhancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,24 +256,23 @@ def extract_text_enhanced(
reverse=True,
)

# Extract text from each region
texts = []
# Keep each string paired with the region that produced it. Indexing
# the original region list with the filtered text list mis-labels
# dialog after a short OCR result is dropped.
extracted: list[tuple[dict, str]] = []
for region in regions:
text = self.extract_text_from_region(image, region)
if text and len(text.strip()) > 2:
texts.append(text)

# Combine texts, prioritizing dialog
if texts:
# If we have dialog text, use it primarily
dialog_texts = [
t
for i, t in enumerate(texts)
if regions[i]["type"] == TextRegion.DIALOG_BOX
]
if dialog_texts:
return " ".join(dialog_texts)
else:
return " ".join(texts)

return ""
extracted.append((region, text))

if not extracted:
return ""

dialog_texts = [
text
for region, text in extracted
if region["type"] == TextRegion.DIALOG_BOX
]
if dialog_texts:
return " ".join(dialog_texts)
return " ".join(text for _, text in extracted)
132 changes: 61 additions & 71 deletions pokemon_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
Version: 0.0.7
"""

import contextlib
import logging
import random
import re
import time
from collections import Counter, deque
from dataclasses import dataclass
Expand All @@ -20,6 +20,13 @@

logger = logging.getLogger(__name__)

# Whole-word button tokens. Exact token match keeps START from collapsing
# into A ("A" is a substring of "START") and ignores prose like "GO EAST".
_BUTTON_TOKENS = frozenset({"START", "SELECT", "RIGHT", "LEFT", "DOWN", "UP", "A", "B"})
# WAIT N is applied as N * 15 frames. Cap N so a model reply cannot stall the emulator.
_MAX_WAIT_UNITS = 30
_BUTTON_WORD = re.compile(r"[A-Z]+")


@dataclass
class Observation:
Expand Down Expand Up @@ -864,7 +871,6 @@ def _dialog_shortcut_policy(self, obs: "Observation") -> str | None:

def _llm_policy(self, obs: "Observation") -> str:
"""Ask the LLM for an action; falls back to heuristics on failure."""
llm_start_time = None
try:
prompt = self.get_prompt()

Expand All @@ -877,16 +883,11 @@ def _llm_policy(self, obs: "Observation") -> str:
)
llm_duration = time.time() - llm_start_time

# Record LLM metrics
# Call counts live on the provider (it has token and error detail).
# Recording again here doubled total_calls and distorted latency.
if self.metrics:
self.metrics.performance.record_llm_time(llm_duration)
self.metrics.llm.record_call(llm_duration)
except Exception as e:
llm_duration = (
time.time() - llm_start_time if llm_start_time is not None else 0.0
)
if self.metrics:
self.metrics.llm.record_call(llm_duration, error=True)
logger.warning(f"LLM call failed: {e}")
logger.warning("Using fallback action based on game state")
return self._llm_fallback(obs)
Expand All @@ -913,50 +914,56 @@ def _llm_fallback(self, obs: "Observation") -> str:
# during character creation)
return "A"

def _match_button_token(self, text: str) -> str | None:
"""First whole-word button in text.

Substring matching treated START as A because ``"A" in "START"``.
Tokens are matched exactly, so START and SELECT stay intact and
prose like "GO EAST" does not become A.
"""
for token in _BUTTON_WORD.findall(text):
if token in _BUTTON_TOKENS:
return token
return None

def _match_wait_units(self, text: str) -> int | None:
"""WAIT count from a reply, clamped to ``_MAX_WAIT_UNITS``."""
match = re.search(r"\bWAIT(?:\s+(\d+))?\b", text)
if match is None:
return None
if match.group(1) is None:
return 10
return min(int(match.group(1)), _MAX_WAIT_UNITS)

def _block_creation_b(self, obs: "Observation", button: str) -> str:
"""B cancels a new game; substitute A while naming is in progress."""
if button == "B" and self._in_creation_window():
logger.info(
f"[CHARACTER_CREATION] Blocked B button, using A instead (step {obs.step_count})"
)
return "A"
return button

def _parse_llm_response(self, obs: "Observation", response: str) -> str:
"""Extract a valid button action from the LLM's raw response."""
response_clean = response.strip().upper()

# CRITICAL: Final check - prevent B during character creation
if self._in_creation_window() and (
response_clean == "B" or response_clean.startswith("B")
):
logger.info(
f"[CHARACTER_CREATION] Blocked B press from LLM, using A instead (step {obs.step_count})"
)
response_clean = "A"

valid_buttons = ["UP", "DOWN", "LEFT", "RIGHT", "A", "B", "SELECT", "START"]

# Check for direct match first (most common case)
for button in valid_buttons:
if button in response_clean:
# CRITICAL: Block B during character creation even if found in response
if button == "B" and self._in_creation_window():
logger.info(
f"[CHARACTER_CREATION] Blocked B button, using A instead (step {obs.step_count})"
)
return self._cache_action(obs, "A")
# Extract the button
if "," in response_clean:
# Handle comma-separated
parts = [p.strip() for p in response_clean.split(",")]
valid_parts = [p for p in parts if p in valid_buttons]
action = (
", ".join(valid_parts[:2]) if valid_parts else button
) # Max 2 actions
else:
action = button
return self._cache_action(obs, action)

# Check for WAIT command
if "WAIT" in response_clean:
parts = response_clean.split()
action = "WAIT 10"
if len(parts) >= 2:
with contextlib.suppress(ValueError):
action = f"WAIT {int(parts[1])}"
return self._cache_action(obs, action)
if "," in response_clean:
buttons = []
for part in response_clean.split(","):
button = self._match_button_token(part)
if button is not None:
buttons.append(self._block_creation_b(obs, button))
if buttons:
return self._cache_action(obs, ", ".join(buttons[:2]))
else:
button = self._match_button_token(response_clean)
if button is not None:
return self._cache_action(obs, self._block_creation_b(obs, button))

wait_units = self._match_wait_units(response_clean)
if wait_units is not None:
return self._cache_action(obs, f"WAIT {wait_units}")

# Fallback: START while still orienting, A afterwards
action = "START" if len(self.action_history) < 5 else "A"
Expand Down Expand Up @@ -1008,29 +1015,12 @@ def step(self) -> dict:
completed_goals=completed,
)

# Check if strategy suggests an action
# Do not apply a strategy suggestion here. suggest_action_for_goal
# returns raw A/DOWN/START for menus and dialog, and taking it
# skipped the policy chain (naming scripts, menu escape, dialog
# loops) on most steps. Overworld routing still runs inside
# get_action() via _route_policy.
action = None
if self.strategy:
current_goal = self.strategy.get_current_goal()
if current_goal:
memory_data = {
"player_position": pre_state.get("player_position"),
"current_map": pre_state.get("current_map", {}),
"party": pre_state.get("party", []),
"health": pre_state.get("health", {}),
}
suggested_action = self.strategy.suggest_action_for_goal(
current_goal, pre_game_state, memory_data
)
# Use strategy suggestion if available and not in pure exploration mode
# Calculate action diversity for adaptive exploration
action_diversity = (
self.calculate_action_entropy(window=15) / 3.0
) # Normalize to 0-1 range
if suggested_action and not self.strategy.should_explore(
self.stuck_count, action_diversity
):
action = suggested_action

# Force exploration when stuck (before getting action).
# Overworld only: random arrows inside a menu, dialog, or transition
Expand Down
10 changes: 6 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@ def sample_memory_data():
0xD163: 1, # 1 Pokemon in party
# First Pokemon data (simplified)
0xD16B: 25, # Species (Pikachu)
0xD16C: 0x20, # HP current low byte
0xD16C: 0x20, # HP current low byte (offset 1)
0xD16D: 0x03, # HP current high byte (0x0320 = 800)
0xD16E: 0x20, # HP max low byte
0xD16F: 0x03, # HP max high byte (0x0320 = 800)
0xD19C: 50, # Level (0xD16B + 33 = 0xD19C)
0xD16E: 0x00, # Box level (offset 3), not max HP
0xD16F: 0x00, # Status (offset 4)
0xD18C: 50, # Level (0xD16B + 33)
0xD18D: 0x20, # Max HP low byte (offset 34)
0xD18E: 0x03, # Max HP high byte (0x0320 = 800)
# Inventory
0xD31D: 2, # 2 items
0xD31E: 1, # Item 1 ID
Expand Down
Loading
Loading