Spaces:
Sleeping
Sleeping
Clarelec
feat: Enhance agent strategy with a detailed system prompt, new state tracking, and update the README to a French implementation report.
e97ef73 | """ | |
| Student MCP Server for Text Adventure Games | |
| This MCP server provides tools and resources for an AI agent to play text | |
| adventure games. Includes state tracking, mapping, and checkpoint utilities. | |
| Tools (actions with side effects): | |
| play_action(action: str) β Execute a game command | |
| get_valid_actions() β List valid actions (10s timeout) | |
| save_checkpoint(name: str) β Save game state | |
| load_checkpoint(name: str) β Restore game state | |
| Resources (free read-only context): | |
| game://inventory β Current inventory with object states | |
| game://state β Location, score, moves, failed actions | |
| game://history β Last 8 actions with β/β/β markers | |
| game://map β Explored locations, exits (tried/blocked) | |
| game://dictionary β Valid vocabulary words by POS | |
| game://unexplored_exits β Untried exits at current room | |
| Test your server with: | |
| fastmcp dev submission_template/mcp_server.py | |
| Then open the MCP Inspector in your browser to test the tools interactively. | |
| """ | |
| import sys | |
| import os | |
| import logging | |
| import signal | |
| from collections import deque | |
| from typing import Optional, Any | |
| from dataclasses import dataclass, field | |
| # Add parent directory to path to import games module | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| from fastmcp import FastMCP | |
| from games.zork_env import TextAdventureEnv | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # ============================================================================= | |
| # Create the MCP Server | |
| # ============================================================================= | |
| mcp = FastMCP("Student Text Adventure Server") | |
| # ============================================================================= | |
| # Game State Management | |
| # ============================================================================= | |
| class GameManager: | |
| """ | |
| Manages the text adventure game state with comprehensive tracking. | |
| Features: | |
| - Action history tracking | |
| - Location exploration mapping | |
| - Score and progress monitoring | |
| - Checkpoint save/load functionality | |
| - Object discovery tracking | |
| """ | |
| # Direction aliases for movement tracking | |
| DIRECTION_ALIASES = { | |
| "n": "north", "s": "south", "e": "east", "w": "west", | |
| "u": "up", "d": "down", "ne": "northeast", "nw": "northwest", | |
| "se": "southeast", "sw": "southwest" | |
| } | |
| MOVEMENT_ACTIONS = { | |
| "north", "south", "east", "west", "up", "down", | |
| "northeast", "northwest", "southeast", "southwest", | |
| "enter", "exit", "climb", "go", "n", "s", "e", "w", "u", "d", | |
| "ne", "nw", "se", "sw" | |
| } | |
| # Directions to check in observations for smart exit detection | |
| DIRECTION_WORDS = { | |
| "north", "south", "east", "west", "up", "down", | |
| "northeast", "northwest", "southeast", "southwest", | |
| } | |
| def __init__(self): | |
| self.env: Optional[TextAdventureEnv] = None | |
| self.state: Optional[Any] = None | |
| self.game_name: str = "" | |
| # State tracking | |
| self.history: deque = deque(maxlen=100) # (action, observation, score_change) tuples | |
| self.explored_locations: dict[str, dict] = {} # location -> {exits, items, description} | |
| self.current_location: str = "" | |
| self.previous_location: str = "" | |
| self.discovered_items: set[str] = set() # All items ever seen | |
| self.visited_count: dict[str, int] = {} # How many times each location visited | |
| # Inventory tracking | |
| self.current_inventory: list[str] = [] # Currently held items | |
| self.inventory_history: list[tuple[int, str, str]] = [] # (move, action, item_change) | |
| # Exit tracking per room β for unexplored exit detection | |
| self.tried_exits: dict[str, dict[str, str]] = {} # location -> {direction -> result} | |
| self.failed_actions_per_loc: dict[str, list[str]] = {} # location -> [action1, ...] | |
| # Checkpoint system | |
| self.checkpoints: dict[str, tuple] = {} # name -> (env_state, game_manager_state) | |
| # Progress tracking | |
| self.max_score_achieved: int = 0 | |
| self.total_actions: int = 0 | |
| self.successful_actions: int = 0 # Actions that had visible effect | |
| self.last_error: Optional[str] = None # Track last error for debugging | |
| # Loop detection via state hashing | |
| self.state_history: list[str] = [] # List of state hashes | |
| self.state_history: list[str] = [] # List of state hashes | |
| self.loop_detected: bool = False | |
| self.action_history_hashes: set[tuple[int, str]] = set() # (state_hash, action) | |
| def initialize(self, game: str = "zork1") -> str: | |
| """Initialize or reset the game.""" | |
| self.game_name = game | |
| self.env = TextAdventureEnv(game) | |
| self.state = self.env.reset() | |
| # Reset state tracking | |
| self.history.clear() | |
| self.explored_locations.clear() | |
| self.current_location = self._extract_location(self.state.observation) | |
| self.previous_location = "" | |
| self.discovered_items.clear() | |
| self.visited_count.clear() | |
| self.tried_exits.clear() | |
| self.failed_actions_per_loc.clear() | |
| self.checkpoints.clear() | |
| self.max_score_achieved = 0 | |
| self.total_actions = 0 | |
| self.successful_actions = 0 | |
| # Reset loop detection | |
| self.state_history.clear() | |
| self.loop_detected = False | |
| self.state_history.clear() | |
| self.loop_detected = False | |
| self.action_history_hashes = set() # (state_hash, action) | |
| self._update_state_hash() | |
| # Initialize current location | |
| self._update_location_info(self.state.observation) | |
| return self.state.observation | |
| def _update_state_hash(self): | |
| """Update state hash history and check for loops.""" | |
| if self.env and hasattr(self.env.env, 'get_world_state_hash'): | |
| try: | |
| current_hash = self.env.env.get_world_state_hash() | |
| self.state_history.append(current_hash) | |
| # Check if this state has been seen frequently recently | |
| # If we've seen this exact state > 3 times in the last 20 moves, it's likely a loop | |
| recent_history = self.state_history[-20:] | |
| if recent_history.count(current_hash) > 3: | |
| self.loop_detected = True | |
| else: | |
| self.loop_detected = False | |
| except Exception: | |
| pass | |
| def _extract_location(self, observation: str) -> str: | |
| """Extract location name from observation. | |
| Uses Jericho's get_player_location() when available for reliable | |
| Z-machine-level tracking. Falls back to parsing observation text. | |
| """ | |
| # Try Jericho's built-in location tracking first | |
| if self.env and hasattr(self.env.env, 'get_player_location'): | |
| try: | |
| loc_obj = self.env.env.get_player_location() | |
| if loc_obj and hasattr(loc_obj, 'name') and loc_obj.name: | |
| return loc_obj.name | |
| except Exception: | |
| pass | |
| # Fallback: parse observation text | |
| # Only use the first line if it looks like a room name | |
| # (short, title-like, not a full sentence with verbs) | |
| lines = observation.strip().split('\n') | |
| for line in lines: | |
| clean = line.strip() | |
| if not clean or clean.startswith('>') or clean.startswith('['): | |
| continue | |
| # Room names are typically short and title-cased | |
| # Reject lines that are full sentences (contain common verbs) | |
| if len(clean) < 50 and not any(w in clean.lower() for w in | |
| ['look', 'not ', 'can\'t', 'is ', 'there', 'you ', 'that ', 'it ']): | |
| return clean | |
| return self.current_location or "Unknown" | |
| def _normalize_direction(self, action: str) -> str: | |
| """Normalize direction aliases to full names.""" | |
| action_lower = action.lower().strip() | |
| return self.DIRECTION_ALIASES.get(action_lower, action_lower) | |
| def _is_movement_action(self, action: str) -> bool: | |
| """Check if action is a movement command.""" | |
| action_lower = action.lower().strip() | |
| first_word = action_lower.split()[0] if action_lower.split() else "" | |
| return first_word in self.MOVEMENT_ACTIONS | |
| def _extract_items_from_observation(self, observation: str) -> list[str]: | |
| """Try to extract mentioned items from observation text.""" | |
| # Common item-related keywords | |
| item_keywords = ["take", "get", "lamp", "sword", "key", "bottle", | |
| "leaflet", "mailbox", "rope", "knife", "bag", | |
| "lantern", "egg", "jewel", "coin", "torch"] | |
| found_items = [] | |
| obs_lower = observation.lower() | |
| for keyword in item_keywords: | |
| if keyword in obs_lower: | |
| found_items.append(keyword) | |
| return found_items | |
| def _describe_object_state(self, obj, prev_attrs: list = None) -> str: | |
| """ | |
| Describe the state of a ZObject in a game-agnostic way. | |
| ZObject.attr is a 32-element numpy uint8 array (0/1 per bit). | |
| Since attribute numbering varies per game (Inform 6 vs 7 etc.), | |
| we DON'T try to map bit numbers to names. | |
| Instead, we: | |
| 1. Compare current attrs vs previous snapshot to detect changes | |
| 2. Use the object's name + child status for useful context | |
| """ | |
| labels = [] | |
| try: | |
| if hasattr(obj, 'attr'): | |
| current_attrs = [int(v) for v in obj.attr] | |
| # If we have a previous snapshot, detect changes | |
| if prev_attrs is not None: | |
| changed_bits = [] | |
| for i, (curr, prev) in enumerate(zip(current_attrs, prev_attrs)): | |
| if curr != prev: | |
| direction = "ON" if curr == 1 else "OFF" | |
| changed_bits.append(f"bit{i}β{direction}") | |
| if changed_bits: | |
| labels.append(f"state changed: {', '.join(changed_bits)}") | |
| else: | |
| labels.append("unchanged") | |
| # Check if the object has children (= container with things inside) | |
| if hasattr(obj, 'child') and obj.child > 0: | |
| labels.append("has contents") | |
| except Exception: | |
| pass | |
| return f" ({', '.join(labels)})" if labels else "" | |
| def _get_world_objects_map(self) -> dict: | |
| """Get all world objects indexed by ID for tree traversal.""" | |
| if not self.env or not hasattr(self.env.env, 'get_world_objects'): | |
| return {} | |
| try: | |
| return {o.num: o for o in self.env.env.get_world_objects()} | |
| except Exception: | |
| return {} | |
| def _get_children_recursive(self, parent_obj, obj_map, depth=0, max_depth=3) -> list[dict]: | |
| """Recursively find children of an object using the sibling chain.""" | |
| children = [] | |
| if depth > max_depth or not hasattr(parent_obj, 'child') or parent_obj.child <= 0: | |
| return children | |
| curr_id = parent_obj.child | |
| while curr_id > 0 and curr_id in obj_map: | |
| obj = obj_map[curr_id] | |
| # Get basic info | |
| name = obj.name if hasattr(obj, 'name') else str(obj) | |
| state = self._describe_object_state(obj) | |
| # Recurse | |
| grandkids = self._get_children_recursive(obj, obj_map, depth + 1, max_depth) | |
| children.append({ | |
| "name": name, | |
| "state": state, | |
| "contents": grandkids | |
| }) | |
| # Move to next sibling | |
| curr_id = obj.sibling if hasattr(obj, 'sibling') else 0 | |
| return children | |
| def get_inventory_with_state(self) -> list[dict]: | |
| """ | |
| Get inventory items using Jericho's ZObject API with state tracking. | |
| Compares current attributes against previous snapshots to detect | |
| state changes (e.g., torch lit β unlit). | |
| Returns list of dicts: [{"name": str, "state": str}] | |
| """ | |
| items = [] | |
| if not self.env or not self.env.env: | |
| return items | |
| # Initialize attribute snapshot storage | |
| if not hasattr(self, '_item_attr_snapshots'): | |
| self._item_attr_snapshots = {} | |
| try: | |
| inv_objects = self.env.env.get_inventory() | |
| for obj in inv_objects: | |
| name = obj.name if hasattr(obj, 'name') else str(obj) | |
| obj_num = obj.num if hasattr(obj, 'num') else id(obj) | |
| # Get previous snapshot for comparison | |
| prev_attrs = self._item_attr_snapshots.get(obj_num) | |
| state_desc = self._describe_object_state(obj, prev_attrs) | |
| # Save current attrs as snapshot for next comparison | |
| if hasattr(obj, 'attr'): | |
| self._item_attr_snapshots[obj_num] = [int(v) for v in obj.attr] | |
| items.append({ | |
| "name": name, | |
| "state": state_desc, | |
| }) | |
| except Exception: | |
| pass | |
| return items | |
| def get_inventory_recursive(self) -> list[dict]: | |
| """Get inventory with full hierarchy and state.""" | |
| if not self.env or not hasattr(self.env.env, 'get_inventory'): | |
| return [] | |
| try: | |
| # We need the full map to traverse children | |
| obj_map = self._get_world_objects_map() | |
| # Get top-level inventory items | |
| inv_roots = self.env.env.get_inventory() | |
| items = [] | |
| for obj in inv_roots: | |
| name = obj.name if hasattr(obj, 'name') else str(obj) | |
| state = self._describe_object_state(obj) | |
| contents = self._get_children_recursive(obj, obj_map) | |
| items.append({ | |
| "name": name, | |
| "state": state, | |
| "contents": contents | |
| }) | |
| return items | |
| except Exception: | |
| return [] | |
| def _extract_exits_from_observation(self, observation: str) -> set[str]: | |
| """Parse observation text for mentioned compass directions. | |
| Scans the room description for direction words to identify | |
| likely exits without blind brute-force. | |
| """ | |
| obs_lower = observation.lower() | |
| found = set() | |
| for d in self.DIRECTION_WORDS: | |
| if d in obs_lower: | |
| found.add(d) | |
| return found | |
| def _update_location_info(self, observation: str): | |
| """Update information about the current location.""" | |
| location = self.current_location | |
| if location not in self.explored_locations: | |
| self.explored_locations[location] = { | |
| "exits": set(), | |
| "items_seen": set(), | |
| "description": observation[:500], | |
| "mentioned_exits": set(), | |
| "first_visit": self.total_actions, | |
| "times_visited": 0 | |
| } | |
| # Update visit count | |
| self.explored_locations[location]["times_visited"] += 1 | |
| self.visited_count[location] = self.visited_count.get(location, 0) + 1 | |
| # Parse observation for mentioned exits | |
| mentioned = self._extract_exits_from_observation(observation) | |
| self.explored_locations[location]["mentioned_exits"] = mentioned | |
| # Try to find items | |
| items = self._extract_items_from_observation(observation) | |
| for item in items: | |
| self.discovered_items.add(item) | |
| self.explored_locations[location]["items_seen"].add(item) | |
| def _update_map_connection(self, action: str, new_location: str): | |
| """Record a connection between locations on the map.""" | |
| if self.previous_location and self.previous_location != new_location: | |
| direction = self._normalize_direction(action.split()[0]) | |
| if self.previous_location not in self.explored_locations: | |
| self.explored_locations[self.previous_location] = { | |
| "exits": set(), | |
| "items_seen": set(), | |
| "description": "", | |
| "first_visit": self.total_actions, | |
| "times_visited": 1 | |
| } | |
| self.explored_locations[self.previous_location]["exits"].add( | |
| f"{direction} -> {new_location}" | |
| ) | |
| def step(self, action: str) -> str: | |
| """Execute an action and return the result with enhanced tracking.""" | |
| if self.env is None: | |
| self.initialize() | |
| old_score = self.state.score if self.state else 0 | |
| old_observation = self.state.observation if self.state else "" | |
| old_location = self.current_location | |
| old_location = self.current_location | |
| # Loop Prevention: Strict State-Action Check | |
| # If we've done this exact action in this exact state before, strictly block it. | |
| # This forces the agent to try something else instead of looping. | |
| current_hash = 0 | |
| if self.env and hasattr(self.env.env, 'get_world_state_hash'): | |
| try: | |
| current_hash = self.env.env.get_world_state_hash() | |
| action_key = (current_hash, action.lower().strip()) | |
| if action_key in self.action_history_hashes: | |
| return f"β Loop prevented: You already tried '{action}' in this exact state. Try something different!" | |
| self.action_history_hashes.add(action_key) | |
| except Exception: | |
| pass | |
| self.state = self.env.step(action) | |
| self.total_actions += 1 | |
| score_change = self.state.score - old_score | |
| if self.state.score > self.max_score_achieved: | |
| self.max_score_achieved = self.state.score | |
| # Track if action had effect | |
| action_had_effect = old_observation != self.state.observation | |
| if action_had_effect: | |
| self.successful_actions += 1 | |
| # Update history with success/fail marker | |
| self.history.append((action, self.state.observation, score_change)) | |
| # Track movement exits and failed actions | |
| new_location = self._extract_location(self.state.observation) | |
| if self._is_movement_action(action): | |
| direction = self._normalize_direction(action.split()[0]) | |
| if new_location != self.current_location: | |
| # Successful movement | |
| self.previous_location = self.current_location | |
| self._update_map_connection(action, new_location) | |
| # Record this exit as successful | |
| if old_location not in self.tried_exits: | |
| self.tried_exits[old_location] = {} | |
| self.tried_exits[old_location][direction] = f"β {new_location}" | |
| self.current_location = new_location | |
| self._update_location_info(self.state.observation) | |
| else: | |
| # Movement failed β record it | |
| if old_location not in self.tried_exits: | |
| self.tried_exits[old_location] = {} | |
| self.tried_exits[old_location][direction] = "BLOCKED" | |
| # Auto-block: if rejection lists valid exits, block all others | |
| # e.g. "only doorway to northwest and west" β block n,s,e,ne,se,sw,up,down | |
| mentioned_dirs = self._extract_exits_from_observation(self.state.observation) | |
| if mentioned_dirs: | |
| all_dirs = {"north","south","east","west","northeast","northwest", | |
| "southeast","southwest","up","down"} | |
| for d in all_dirs - mentioned_dirs: | |
| if d not in self.tried_exits[old_location]: | |
| self.tried_exits[old_location][d] = "BLOCKED" | |
| # Track non-movement failed actions | |
| if not action_had_effect or any(ind in self.state.observation.lower() for ind in | |
| ["can't", "not ", "already", "nothing", "impossible"]): | |
| loc = self.current_location | |
| if loc not in self.failed_actions_per_loc: | |
| self.failed_actions_per_loc[loc] = [] | |
| if action not in self.failed_actions_per_loc[loc]: | |
| self.failed_actions_per_loc[loc].append(action) | |
| self._update_state_hash() | |
| return self.state.observation | |
| def get_score(self) -> int: | |
| """Get current score.""" | |
| return self.state.score if self.state else 0 | |
| def get_moves(self) -> int: | |
| """Get number of moves taken.""" | |
| return self.state.moves if self.state else 0 | |
| def get_formatted_history(self, n: int = 10) -> str: | |
| """Get formatted recent history.""" | |
| if not self.history: | |
| return "No actions taken yet." | |
| recent = list(self.history)[-n:] | |
| lines = [] | |
| for i, (action, obs, score_delta) in enumerate(recent, 1): | |
| # Truncate observation for readability | |
| obs_short = obs[:100].replace('\n', ' ') | |
| if len(obs) > 100: | |
| obs_short += "..." | |
| score_str = f" (+{score_delta})" if score_delta > 0 else "" | |
| lines.append(f" {i}. > {action}{score_str}") | |
| lines.append(f" {obs_short}") | |
| return "\n".join(lines) | |
| def save_checkpoint(self, name: str) -> bool: | |
| """Save current game state to a named checkpoint.""" | |
| if self.env is None: | |
| return False | |
| try: | |
| env_state = self.env.save_state() | |
| manager_state = { | |
| "current_location": self.current_location, | |
| "previous_location": self.previous_location, | |
| "score": self.state.score, | |
| "moves": self.state.moves, | |
| "max_score_achieved": self.max_score_achieved | |
| } | |
| self.checkpoints[name] = (env_state, manager_state) | |
| return True | |
| except Exception: | |
| return False | |
| def load_checkpoint(self, name: str) -> bool: | |
| """Load a previously saved checkpoint.""" | |
| if name not in self.checkpoints: | |
| return False | |
| try: | |
| env_state, manager_state = self.checkpoints[name] | |
| self.env.load_state(env_state) | |
| # Restore state from saved checkpoint without executing a step | |
| # This avoids wasting a game move on "look" | |
| self.current_location = manager_state["current_location"] | |
| self.previous_location = manager_state["previous_location"] | |
| # Get current observation from environment without stepping | |
| if hasattr(self.env, 'get_state'): | |
| self.state = self.env.get_state() | |
| else: | |
| # Fallback: create minimal state from saved data | |
| from games.zork_env import GameState | |
| self.state = GameState( | |
| observation=f"Restored to {self.current_location}", | |
| score=manager_state.get("score", 0), | |
| moves=manager_state.get("moves", 0), | |
| done=False, | |
| info={} | |
| ) | |
| return True | |
| except Exception: | |
| return False | |
| # Global game manager | |
| _game = GameManager() | |
| def get_game() -> GameManager: | |
| """Get or initialize the game manager.""" | |
| global _game | |
| if _game.env is None: | |
| # Get game from environment variable (set by evaluator) | |
| game = os.environ.get("GAME", "zork1") | |
| _game.initialize(game) | |
| return _game | |
| # ============================================================================= | |
| # MCP Tools | |
| # ============================================================================= | |
| def play_action(action: str) -> str: | |
| """ | |
| Execute a game command and return the result. | |
| This is the main tool for interacting with the game. | |
| Args: | |
| action: The command to execute (e.g., "north", "take lamp", "open mailbox") | |
| Returns: | |
| The game's response to the action, plus score information | |
| Valid commands include: | |
| - Movement: north, south, east, west, up, down, enter, exit | |
| - Objects: take <item>, drop <item>, open <thing>, examine <thing> | |
| - Other: look, inventory, read <thing>, turn on lamp | |
| """ | |
| game = get_game() | |
| old_score = game.get_score() | |
| result = game.step(action) | |
| new_score = game.get_score() | |
| score_change = new_score - old_score | |
| # Build response with score info | |
| response = result | |
| if score_change > 0: | |
| response += f"\n\nπ +{score_change} points! (Total: {new_score})" | |
| elif score_change < 0: | |
| response += f"\n\nβ οΈ {score_change} points. (Total: {new_score})" | |
| else: | |
| response += f"\n\n[Score: {new_score} | Moves: {game.get_moves()}]" | |
| if game.state.done: | |
| response += "\n\nπ GAME OVER" | |
| return response | |
| def save_checkpoint(name: str) -> str: | |
| """ | |
| Save the current game state to a named checkpoint. | |
| Args: | |
| name: A memorable name for this checkpoint (e.g., "before_troll", "got_lamp") | |
| Returns: | |
| Confirmation message indicating success or failure. | |
| Use this before risky actions or to mark progress! | |
| """ | |
| game = get_game() | |
| if game.save_checkpoint(name): | |
| return f"""πΎ CHECKPOINT SAVED | |
| ββββββββββββββββββββββββββββ | |
| Name: "{name}" | |
| Location: {game.current_location} | |
| Score: {game.get_score()} | |
| Moves: {game.get_moves()} | |
| Use load_checkpoint("{name}") to return here.""" | |
| else: | |
| return f"β Failed to save checkpoint '{name}'. Make sure the game is initialized." | |
| def load_checkpoint(name: str) -> str: | |
| """ | |
| Load a previously saved checkpoint. | |
| Args: | |
| name: The name of the checkpoint to load | |
| Returns: | |
| The game state after loading, or error message if checkpoint not found. | |
| """ | |
| game = get_game() | |
| if not game.checkpoints: | |
| return "β No checkpoints saved yet. Use save_checkpoint first!" | |
| if name not in game.checkpoints: | |
| available = ", ".join(game.checkpoints.keys()) | |
| return f"β Checkpoint '{name}' not found.\nAvailable checkpoints: {available}" | |
| if game.load_checkpoint(name): | |
| return f"""βͺ CHECKPOINT LOADED | |
| ββββββββββββββββββββββββββββ | |
| Restored to: "{name}" | |
| Location: {game.current_location} | |
| Score: {game.get_score()} | |
| {game.state.observation if game.state else ''}""" | |
| else: | |
| return f"β Failed to load checkpoint '{name}'." | |
| # ============================================================================= | |
| # MCP Resources (read-only context the agent can query for free) | |
| # ============================================================================= | |
| def get_dictionary_resource() -> str: | |
| """Game vocabulary categorized by part of speech (verbs, nouns, directions).""" | |
| game = get_game() | |
| if not game.env or not hasattr(game.env.env, 'get_dictionary'): | |
| return "Dictionary not available." | |
| try: | |
| vocab = game.env.env.get_dictionary() | |
| verbs, nouns, directions, other = [], [], [], [] | |
| for w in vocab: | |
| word = str(w) | |
| if hasattr(w, 'is_dir') and w.is_dir: | |
| directions.append(word) | |
| elif hasattr(w, 'is_verb') and w.is_verb: | |
| verbs.append(word) | |
| elif hasattr(w, 'is_noun') and w.is_noun: | |
| nouns.append(word) | |
| else: | |
| other.append(word) | |
| parts = [] | |
| if verbs: | |
| parts.append(f"Verbs ({len(verbs)}): {', '.join(sorted(verbs)[:40])}") | |
| if nouns: | |
| parts.append(f"Nouns ({len(nouns)}): {', '.join(sorted(nouns)[:40])}") | |
| if directions: | |
| parts.append(f"Directions ({len(directions)}): {', '.join(sorted(directions))}") | |
| return "\n".join(parts) if parts else f"Total words: {len(vocab)} (no POS tags available)" | |
| except Exception: | |
| return "Dictionary not available." | |
| def get_inventory_resource() -> str: | |
| """Current inventory with object states, hierarchy, and contents.""" | |
| game = get_game() | |
| items = game.get_inventory_recursive() | |
| def format_item(item, level=0): | |
| indent = " " * level | |
| marker = "π¦ " if level == 0 else "β³ " | |
| lines = [f"{indent}{marker}{item['name']}{item['state']}"] | |
| for child in item['contents']: | |
| lines.extend(format_item(child, level + 1)) | |
| return lines | |
| if items: | |
| all_lines = [] | |
| for item in items: | |
| all_lines.extend(format_item(item)) | |
| return "\n".join(all_lines) | |
| return "Empty-handed." | |
| def get_game_state_resource() -> str: | |
| """Current game state: location, score, moves, exploration progress.""" | |
| game = get_game() | |
| # Try to get max score from Jericho | |
| max_possible = "?" | |
| if game.env and hasattr(game.env.env, 'get_max_score'): | |
| try: | |
| max_possible = game.env.env.get_max_score() | |
| except Exception: | |
| pass | |
| parts = [ | |
| f"Location: {game.current_location}", | |
| f"Score: {game.get_score()}/{max_possible} (best: {game.max_score_achieved})", | |
| f"Moves: {game.get_moves()}", | |
| f"Locations explored: {len(game.explored_locations)}", | |
| f"Actions: {game.successful_actions}/{game.total_actions} successful", | |
| ] | |
| if game.loop_detected: | |
| parts.append("β οΈ LOOP DETECTED β try a completely different action!") | |
| # Show failed actions at current location | |
| failed = game.failed_actions_per_loc.get(game.current_location, []) | |
| if failed: | |
| parts.append(f"β Failed here: {', '.join(failed[-8:])}") | |
| return "\n".join(parts) | |
| def get_history_resource() -> str: | |
| """Last 5 actions with compact result markers (β scored, β moved, β failed).""" | |
| game = get_game() | |
| if not game.history: | |
| return "No actions taken yet." | |
| recent = list(game.history)[-5:] | |
| lines = [] | |
| for action, obs, score_delta in recent: | |
| # Compact markers | |
| if score_delta > 0: | |
| marker = f"β +{score_delta}" | |
| elif any(ind in obs.lower() for ind in ["can't", "not ", "already", "nothing"]): | |
| marker = "β" | |
| else: | |
| marker = "β" | |
| obs_short = obs[:80].replace('\n', ' ') | |
| lines.append(f" {marker} {action}: {obs_short}") | |
| return "\n".join(lines) | |
| def get_map_resource() -> str: | |
| """Explored locations with exits (β = leads somewhere, β = blocked).""" | |
| game = get_game() | |
| if not game.explored_locations: | |
| return "No locations explored yet." | |
| lines = [] | |
| for loc, info in sorted(game.explored_locations.items()): | |
| marker = "π" if loc == game.current_location else " " | |
| lines.append(f"{marker} {loc} (visited {info.get('times_visited', 1)}x)") | |
| # Show tried exits with their results | |
| tried = game.tried_exits.get(loc, {}) | |
| for direction, result in sorted(tried.items()): | |
| lines.append(f" {'β' if 'β' in result else 'β'} {direction} {result}") | |
| # Show known exits from map connections (that might not be in tried_exits) | |
| for exit_info in sorted(info.get("exits", set())): | |
| lines.append(f" β³ {exit_info}") | |
| items_seen = info.get("items_seen", set()) | |
| if items_seen: | |
| lines.append(f" π¦ Items: {', '.join(sorted(items_seen))}") | |
| return "\n".join(lines) | |
| def get_unexplored_exits_resource() -> str: | |
| """Smart exit info: mentioned exits from room description prioritized over blind guesses.""" | |
| game = get_game() | |
| loc = game.current_location | |
| if not loc: | |
| return "Location unknown." | |
| tried = game.tried_exits.get(loc, {}) | |
| loc_info = game.explored_locations.get(loc, {}) | |
| mentioned = loc_info.get("mentioned_exits", set()) | |
| # Categorize exits | |
| mentioned_untried = [d for d in sorted(mentioned) if d not in tried] | |
| other_untried = [d for d in ["up", "down", "northeast", "northwest", "southeast", "southwest"] | |
| if d not in tried and d not in mentioned] | |
| blocked = [f"{d}" for d, r in tried.items() if r == "BLOCKED"] | |
| succeeded = [f"{d} ({r})" for d, r in tried.items() if r != "BLOCKED"] | |
| parts = [] | |
| if mentioned_untried: | |
| parts.append(f"πͺ LIKELY EXITS (from room description): {', '.join(mentioned_untried)}") | |
| if succeeded: | |
| parts.append(f"β Already explored: {', '.join(succeeded)}") | |
| if blocked: | |
| parts.append(f"β Blocked: {', '.join(blocked)}") | |
| if other_untried: | |
| parts.append(f"? Other untried: {', '.join(other_untried)}") | |
| failed_actions = game.failed_actions_per_loc.get(loc, []) | |
| if failed_actions: | |
| parts.append(f"β Failed actions here: {', '.join(failed_actions[-6:])}") | |
| return "\n".join(parts) if parts else "No exit data yet." | |
| def get_rooms_resource() -> str: | |
| """Knowledge base of all visited rooms: description, exits, objects, and notes.""" | |
| game = get_game() | |
| if not game.explored_locations: | |
| return "No rooms explored yet." | |
| sections = [] | |
| for loc, info in sorted(game.explored_locations.items()): | |
| is_current = (loc == game.current_location) | |
| icon = "π" if is_current else "Item" | |
| # Room Header | |
| part = [f"## {icon} {loc}"] | |
| part.append(f"_{info['description']}_") | |
| # Ground Truth Objects (from Z-Machine tree) | |
| if is_current: | |
| try: | |
| # Use Jericho to inspect the current room's objects | |
| if game.env and hasattr(game.env.env, 'get_player_location'): | |
| loc_obj = game.env.env.get_player_location() | |
| obj_map = game._get_world_objects_map() | |
| # Find everything in the room (children of room object) | |
| # Exclude the player themselves and common scenery noise if possible | |
| params = [] | |
| if loc_obj: | |
| room_contents = game._get_children_recursive(loc_obj, obj_map, max_depth=1) | |
| for item in room_contents: | |
| if "player" not in item['name'].lower() and "self" not in item['name'].lower(): | |
| desc = f"{item['name']}{item['state']}" | |
| if item['contents']: | |
| desc += f" (contains: {', '.join(c['name'] for c in item['contents'])})" | |
| params.append(desc) | |
| if params: | |
| part.append(f"\nπ GROUND TRUTH OBJECTS (Verified): {', '.join(params)}") | |
| except Exception: | |
| pass | |
| # Items Seen (Historical/Regex) | |
| items_seen = info.get("items_seen", set()) | |
| if items_seen: | |
| part.append(f"\nπ Items seen in text: {', '.join(sorted(items_seen))}") | |
| # Exits | |
| part.append(f"\nπͺ Exits: {', '.join(sorted(info.get('exits', [])))}") | |
| sections.append("\n".join(part)) | |
| return "\n\n---\n\n".join(sections) | |
| def get_actions_resource() -> str: | |
| """Smart action suggestions based on Game Grammar + Visible Objects.""" | |
| game = get_game() | |
| parts = [] | |
| # 1. Valid Verbs from Grammar (if available) | |
| verbs = [] | |
| try: | |
| if game.env and hasattr(game.env.env, 'bindings') and 'grammar' in game.env.env.bindings: | |
| # We filter useful templates to avoid spamming the LLM | |
| common = {'take', 'drop', 'open', 'close', 'examine', 'look', 'read', | |
| 'turn', 'push', 'pull', 'move', 'climb', 'enter', 'attack', | |
| 'kill', 'eat', 'drink', 'wear', 'remove', 'search', 'give', | |
| 'fill', 'light', 'throw', 'unlock'} | |
| grammar = game.env.env.bindings['grammar'] | |
| # Extract clean verb templates (e.g. "take [object]") | |
| templates = [] | |
| for g in grammar: | |
| if isinstance(g, bytes): | |
| g = g.decode('utf-8', errors='ignore') | |
| else: | |
| g = str(g) | |
| # Keep verbs starting with our common list | |
| if any(g.startswith(v) for v in common): | |
| # Replace internal Jericho tokens with readable placeholders | |
| clean_g = g.replace('OBJ', '<object>').replace('CREATURE', '<creature>').replace('SOMETHING', '<something>') | |
| if clean_g not in templates: | |
| templates.append(clean_g) | |
| if templates: | |
| parts.append(f"π KNOWN ACTION TEMPLATES:\n" + "\n".join(f"- {t}" for t in sorted(templates)[:25])) | |
| except Exception as e: | |
| parts.append("π COMMON VERBS: take <obj>, drop <obj>, open <obj>, examine <obj>, push <obj>, pull <obj>, turn <obj>, search, attack <creature> with <weapon>") | |
| # 2. Contextual Suggestions (Object + Verb) | |
| suggestions = [] | |
| # Get visible objects (Ground Truth) | |
| visible_names = [] | |
| try: | |
| if game.env and hasattr(game.env.env, 'get_player_location'): | |
| loc_obj = game.env.env.get_player_location() | |
| obj_map = game._get_world_objects_map() | |
| raw_contents = game._get_children_recursive(loc_obj, obj_map, max_depth=0) | |
| visible_names = [i['name'] for i in raw_contents if "player" not in i['name'].lower()] | |
| except Exception: | |
| pass | |
| # Get inventory names | |
| inv_names = [i['name'] for i in game.get_inventory_recursive()] | |
| if visible_names: | |
| parts.append(f"\nπ VISIBLE OBJECTS: {', '.join(visible_names)}") | |
| # Generate interaction pairs | |
| for obj in visible_names[:5]: # limit to first few to avoid spam | |
| suggestions.append(f"examine {obj}") | |
| suggestions.append(f"take {obj}") | |
| if "door" in obj or "chest" in obj or "box" in obj: | |
| suggestions.append(f"open {obj}") | |
| if inv_names: | |
| parts.append(f"\nπ INVENTORY: {', '.join(inv_names)}") | |
| for obj in inv_names[:3]: | |
| suggestions.append(f"examine {obj}") | |
| suggestions.append(f"drop {obj}") | |
| if suggestions: | |
| parts.append(f"\nπ‘ SUGGESTED ACTIONS:\n- " + "\n- ".join(suggestions[:8])) | |
| return "\n".join(parts) | |
| # ============================================================================= | |
| # Run the server | |
| # ============================================================================= | |
| def get_valid_directions_resource() -> str: | |
| """Return a comma-separated list of known valid directions for the current location.""" | |
| try: | |
| game = get_game() | |
| loc = game.current_location | |
| if not loc or loc not in game.explored_locations: | |
| return "" | |
| info = game.explored_locations[loc] | |
| # Combine mentioned exits and successful tried exits | |
| mentioned = info.get("mentioned_exits", set()) | |
| tried = game.tried_exits.get(loc, {}) | |
| valid = set() | |
| # Add mentioned | |
| if mentioned: | |
| valid.update(mentioned) | |
| # Add tried and verify if blocked | |
| for d, res in tried.items(): | |
| if "->" in res: | |
| valid.add(d) | |
| elif "blocked" in res.lower() or "can't" in res.lower(): | |
| if d in valid: | |
| valid.discard(d) | |
| return ", ".join(sorted(valid)) | |
| except Exception: | |
| return "" | |
| if __name__ == "__main__": | |
| # This runs the server with stdio transport (for MCP clients) | |
| mcp.run() | |