commit 4807acee957f20ed5b53b8e312cb73da833211de Author: bobbycxy Date: Sat Nov 22 02:56:57 2025 +0000 Initial commit from Openverse UI diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6ea1c4 --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# GAME DESIGN DOCUMENT — “EchoMaze: The Labyrinth Duel” + +--- + +## 1. Concept Paragraph + +**EchoMaze** is a deterministic, turn-based strategy game set in a shifting underground labyrinth. Two explorers, **Player Sun** and **Player Moon**, race to reach the **Exit Glyph** hidden deep within a maze of corridors. Each turn, players issue one of several movement or tactical commands to navigate or manipulate the maze: **[Move: Direction]**, **[Scan]**, **[Mark]**, or **[Rest]**. The maze layout is fully deterministic and generated based on a fixed random seed to allow reproducibility. **This design is completely unrelated to the negotiation example**; it is a navigation and exploration contest with no bargaining or trade mechanics. + +--- + +## 2. Roles and Win Condition + +- **Roles:** + - *Player Sun (Explorer A)* — Seeks to reach the Exit Glyph first. + - *Player Moon (Explorer B)* — Competes to reach the same goal. + +- **Objective:** + Each player tries to reach the cell containing the **Exit Glyph** before their opponent. + +- **Win Condition:** + - If one player’s position matches the Exit Glyph's location at the end of their move, they **win immediately**. + - If both players reach the Exit Glyph simultaneously in the same round, the game ends in a **draw**. + - If no player reaches the exit within `MAX_TURNS`, the game ends and the player **closer to the Exit Glyph** (by Manhattan distance) **wins**. If distances are equal, it’s a **draw**. + +--- + +## 3. Turn Structure and Determinism + +- Players alternate turns: **Player Sun → Player Moon → repeat**. +- One action per turn. +- After each player has acted an equal number of times, a **round** is completed. +- `MAX_TURNS = 60` total (30 rounds per player). +- Determinism is ensured through a **`maze_seed`** integer: the same seed produces the identical maze layout and Exit Glyph position for reproducibility. + +--- + +## 4. Action Grammar (Machine-Parseable) + +Each player provides one legal action per turn. +**Action format:** + +| Action Type | Token Pattern | Description | Example Valid | Example Invalid | +|--------------|---------------|--------------|----------------|------------------| +| **Move** | `\[Move: (North|South|East|West)\]` | Move one cell in direction if path open | `[Move: North]` | `[Move: Up]` — invalid direction | +| **Scan** | `\[Scan\]` | Reveals adjacent wall/open information | `[Scan]` | `[Scan: East]` — invalid argument | +| **Mark** | `\[Mark\]` | Place a marker on current cell (for tracking visited locations) | `[Mark]` | `[Mark: X]` — arguments unsupported | +| **Rest** | `\[Rest\]` | Skip the turn but recover 1 Focus point | `[Rest]` | `[Rest for a while]` — invalid syntax | + +All tokens are matched case-sensitively. +On validation, anything outside these exact tokens will trigger an invalid move error. + +--- + +## 5. Game State Schema + +Example `game_state` (prettified JSON): + +```json +{ + "maze_seed": 1234, + "turn_count": 8, + "max_turns": 60, + "maze_layout": [ + ["#", "#", "#", "#", "#"], + ["#", "S", ".", ".", "#"], + ["#", ".", "#", "E", "#"], + ["#", ".", ".", ".", "#"], + ["#", "#", "#", "#", "#"] + ], + "exit_location": [2, 3], + "players": { + "Sun": { + "position": [1, 1], + "markers": [[1, 1]], + "focus": 3, + "observations": ["Turn 1: Started at (1,1).", "Turn 2: Moved East."], + "last_action": "[Move: East]" + }, + "Moon": { + "position": [3, 3], + "markers": [], + "focus": 4, + "observations": ["Turn 1: Started at (3,3).", "Turn 2: Scanned nearby walls."], + "last_action": "[Scan]" + } + }, + "public_transcript": [ + "Sun: [Move: East]", + "Moon: [Scan]" + ], + "winner": null, + "is_terminal": false, + "invalid_move_reason": null +} +``` + +--- + +## 6. Initialization Rules + +- `maze_seed` determines deterministic maze generation and Exit Glyph placement. +- Both explorers spawn at opposite corners of the maze (Sun: top-left open cell; Moon: bottom-right open cell). +- Each player starts with: + - `focus = 5` + - Empty `markers` list. +- The **first observation** per player includes their starting coordinates and known adjacent walls. +- The same seed across runs produces the exact same maze walls, exits, and spawn coordinates. + +--- + +## 7. Validation and Error Handling + +**Validation steps:** +1. Extract content inside `\boxed{{}}` using `_extract_answer_content(self, action: str) -> str`. +2. Match against grammar patterns: + - If pattern mismatch → `set_invalid_move("Unrecognized action syntax.")` + - If Move chosen but target cell is wall or outside maze → `set_invalid_move("Cannot move through wall or outside bounds.")` + - If player already at Exit Glyph → ignore turn and end game. + - If player has zero focus and action is not `[Rest]` → `set_invalid_move("Insufficient focus to perform action.")` +3. All invalid moves terminate the game for that player as **automatic loss**. + +--- + +## 8. Terminal Conditions and Scoring + +**Checks each turn:** +1. If player’s position == `exit_location` → **Immediate win** +2. If both reach exit same round → **Draw** +3. If `turn_count >= MAX_TURNS` → distance comparison: + - Compute Manhattan distance for both to Exit Glyph. + - Smaller distance → **Win** + - Equal distance → **Draw** + +**Scores:** +- Winner: +1 point, Loser: 0, Draw: 0.5 each. + +--- + +## 9. Player Prompt Specification + +### Prompt Outline: + +**Theme intro:** +> You are an explorer in the ancient labyrinth of EchoMaze. Your goal is to reach the Exit Glyph before your rival. The maze’s layout is consistent across turns, and every action reshapes your advantage. + +**You can take exactly one of the following actions per turn, placing it within `\boxed{{}}`:** +- `[Move: North]`, `[Move: South]`, `[Move: East]`, `[Move: West]` +- `[Scan]` +- `[Mark]` +- `[Rest]` + +**Rules summary:** +- You cannot move through walls or outside the maze. +- You need Focus > 0 to act (except `[Rest]` recovers 1 Focus). +- The game ends when someone reaches the Exit Glyph or the move limit expires. + +**Format requirement:** +At the end of your response, write your final chosen action inside `\boxed{{}}`. + +**Few-shot examples:** + +Example valid response: +``` +I see a corridor to the east. I will advance through it carefully. +\boxed{{[Move: East]}} +``` + +Example invalid response: +``` +I move upward. +\boxed{{[Move: Up]}} <-- Invalid action (not a defined direction) +``` + +--- + +## 10. API Mapping Plan + +- **`reset(seed)`** + - Generates maze layout deterministically. + - Initializes player positions, Focus, and empty markers. + - Sets `turn_count = 0`, `winner = None`, `is_terminal = False`. + - Returns initial observation dict for both players, including visible adjacent cells. + +- **`step(player_id, action)`** + - Extract content via `_extract_answer_content()`. + - Validate action format and game legality. + - Update positions, Focus, markers, and transcript. + - Check terminal conditions (exit reached, invalid move, or turn limit). + - Append the action description to `public_transcript`. + - Return new observation, reward, and whether the game is terminal. + +- **`_generate_player_prompt(player_id)`** + - Uses the latest `game_state` to prepare a narrative prompt: + - Brief summary of player’s position and known environment. + - Note of Focus value and any markers placed. + - Reminder that actions must be inside `\boxed{{}}`. + - Returns prompt text. + +--- + +## 11. Copy-Check Against the Example + +This design is **entirely original**: +- The theme (labyrinth navigation and strategy) differs completely from any negotiation or economic scenario. +- Resource names like *Focus*, *markers*, *Exit Glyph*, and *maze_seed* are unique. +- Objectives (reach exit first) are spatial and exploratory, not bargaining-based. +- All `game_state` keys and prompt text are unique to the **EchoMaze** concept. + +--- + +**End of Design Document — EchoMaze: The Labyrinth Duel** \ No newline at end of file diff --git a/env.py b/env.py new file mode 100644 index 0000000..71883c0 --- /dev/null +++ b/env.py @@ -0,0 +1,332 @@ +```python +import re +import random +from typing import Any, Dict, List, Optional, Tuple + +import textarena as ta + + +class EchoMazeEnv(ta.Env): + """ + EchoMaze: The Labyrinth Duel + Deterministic, two-player, turn-based maze exploration game. + """ + + def __init__(self, max_turns: int = 60, maze_size: int = 9): + """ + Initialize environment config (not game-state). + """ + self.max_turns = max_turns + self.maze_size = maze_size + self.valid_actions = [ + "[Scan]", + "[Mark]", + "[Rest]", + "[Move: North]", + "[Move: South]", + "[Move: East]", + "[Move: West]", + ] + + # ---------------------------------------------------------------------- + # Reset + # ---------------------------------------------------------------------- + def reset(self, num_players: int, seed: Optional[int] = None): + """ + Resets the environment to an initial state. + + Args: + num_players: Must be 2 + seed: random seed for determinism + """ + if num_players != 2: + raise ValueError("EchoMaze requires exactly 2 players (Sun and Moon).") + + self.state = ta.TwoPlayerState(num_players=num_players, seed=seed, max_turns=self.max_turns) + random.seed(seed) + + # Generate base maze using seed for deterministic layout + maze_layout, exit_location, sun_start, moon_start = self._generate_maze(seed) + + # Build game_state following Stage 1 schema + game_state: Dict[str, Any] = { + "maze_seed": seed, + "turn_count": 0, + "max_turns": self.max_turns, + "maze_layout": maze_layout, + "exit_location": exit_location, + "players": { + "Sun": { + "position": sun_start, + "markers": [], + "focus": 5, + "observations": [ + f"Turn 1: Started at {tuple(sun_start)}." + ], + "last_action": None, + }, + "Moon": { + "position": moon_start, + "markers": [], + "focus": 5, + "observations": [ + f"Turn 1: Started at {tuple(moon_start)}." + ], + "last_action": None, + }, + }, + "public_transcript": [], + "winner": None, + "is_terminal": False, + "invalid_move_reason": None, + } + + # Reset game state + self.state.reset(game_state=game_state, player_prompt_function=self._generate_player_prompt, + role_mapping={0: "Sun", 1: "Moon"}) + + # Announce + self.state.add_observation("Welcome to EchoMaze: The Labyrinth Duel!", ta.ObservationType.GAME_MESSAGE) + self.state.add_observation(f"Exit Glyph hidden at {tuple(exit_location)} (secretly known to system).", + ta.ObservationType.GAME_MESSAGE) + return self.state + + # ---------------------------------------------------------------------- + # Step + # ---------------------------------------------------------------------- + def step(self, action: str) -> Tuple[bool, ta.Info]: + """ + Perform a single environment step for the current player. + """ + player_id = self.state.current_player_id + player_name = "Sun" if player_id == 0 else "Moon" + + self.state.add_observation( + action, + ta.ObservationType.PLAYER_ACTION, + from_id=player_id, + to_id=-1, + ) + + extracted_action = self._extract_answer_content(action) + current_state = self.state.game_state + player_data = current_state["players"][player_name] + + # If game already terminal + if current_state["winner"] or current_state["is_terminal"]: + return self.state.step() + + # --- Validation --- + if extracted_action not in self.valid_actions: + self.state.set_invalid_move("Unrecognized action syntax.") + current_state["invalid_move_reason"] = "Unrecognized action syntax." + current_state["is_terminal"] = True + return self.state.step() + + if player_data["focus"] <= 0 and extracted_action != "[Rest]": + self.state.set_invalid_move("Insufficient focus to perform action.") + current_state["invalid_move_reason"] = "Insufficient focus to perform action." + current_state["is_terminal"] = True + return self.state.step() + + # Execute effect + result_message = "" + if extracted_action.startswith("[Move:"): + direction = extracted_action.split(":")[1].strip(" ]") + result_message = self._process_move(player_name, direction, current_state) + elif extracted_action == "[Scan]": + result_message = self._process_scan(player_name, current_state) + player_data["focus"] -= 1 + elif extracted_action == "[Mark]": + result_message = self._process_mark(player_name, current_state) + player_data["focus"] -= 1 + elif extracted_action == "[Rest]": + result_message = self._process_rest(player_name, current_state) + + player_data["last_action"] = extracted_action + current_state["public_transcript"].append(f"{player_name}: {extracted_action}") + current_state["turn_count"] += 1 + + # --- Check Terminal Conditions after action --- + exit_loc = current_state["exit_location"] + sun_pos = current_state["players"]["Sun"]["position"] + moon_pos = current_state["players"]["Moon"]["position"] + + if sun_pos == exit_loc and moon_pos == exit_loc: + self.state.set_draw("Both players reached the Exit Glyph simultaneously.") + current_state["winner"] = "Draw" + current_state["is_terminal"] = True + elif sun_pos == exit_loc: + self.state.set_winner(0, "Sun reached the Exit Glyph.") + current_state["winner"] = "Sun" + current_state["is_terminal"] = True + elif moon_pos == exit_loc: + self.state.set_winner(1, "Moon reached the Exit Glyph.") + current_state["winner"] = "Moon" + current_state["is_terminal"] = True + elif current_state["turn_count"] >= self.max_turns: + sun_dist = self._manhattan_distance(sun_pos, exit_loc) + moon_dist = self._manhattan_distance(moon_pos, exit_loc) + if sun_dist < moon_dist: + self.state.set_winner(0, "Sun is closer to the Exit Glyph after max turns.") + current_state["winner"] = "Sun" + elif moon_dist < sun_dist: + self.state.set_winner(1, "Moon is closer to the Exit Glyph after max turns.") + current_state["winner"] = "Moon" + else: + self.state.set_draw("Equal distance to Exit Glyph after max turns.") + current_state["winner"] = "Draw" + current_state["is_terminal"] = True + + # Log observation message + self.state.add_observation(result_message, ta.ObservationType.GAME_MESSAGE) + return self.state.step() + + # ---------------------------------------------------------------------- + # Helpers + # ---------------------------------------------------------------------- + def _generate_maze(self, seed: int): + """ + Produces deterministic maze layout with walls (#), open cells (.), Exit (E). + Ensures reproducibility. + """ + size = self.maze_size + random.seed(seed) + maze = [["#" for _ in range(size)] for _ in range(size)] + + # Create random open cells + for i in range(1, size - 1): + for j in range(1, size - 1): + maze[i][j] = "." if random.random() > 0.25 else "#" + + # Place exit + exit_x, exit_y = random.randint(1, size - 2), random.randint(1, size - 2) + maze[exit_x][exit_y] = "E" + + # Find top-left open for Sun + sun_start = self._find_open_cell(maze, from_top=True) + moon_start = self._find_open_cell(maze, from_top=False) + maze[sun_start[0]][sun_start[1]] = "S" # Mark starting + maze[moon_start[0]][moon_start[1]] = "M" + + return maze, [exit_x, exit_y], sun_start, moon_start + + def _find_open_cell(self, maze: List[List[str]], from_top: bool = True) -> List[int]: + size = len(maze) + row_range = range(size) if from_top else range(size - 1, -1, -1) + for i in row_range: + for j in row_range: + if maze[i][j] == ".": + return [i, j] + # Fallback if none open + return [1, 1] if from_top else [size - 2, size - 2] + + def _extract_answer_content(self, action: str) -> str: + """Extract content from \\boxed{}""" + match = re.search(r"\\boxed\{([^}]*)\}", action, re.DOTALL) + if match: + return match.group(1).strip() + return action.strip() + + def _manhattan_distance(self, a: List[int], b: List[int]) -> int: + return abs(a[0] - b[0]) + abs(a[1] - b[1]) + + def _process_move(self, player: str, direction: str, game_state: Dict[str, Any]) -> str: + pos = game_state["players"][player]["position"] + x, y = pos + dx, dy = 0, 0 + if direction == "North": + dx = -1 + elif direction == "South": + dx = 1 + elif direction == "East": + dy = 1 + elif direction == "West": + dy = -1 + + new_x, new_y = x + dx, y + dy + maze = game_state["maze_layout"] + if not (0 <= new_x < len(maze) and 0 <= new_y < len(maze[0])): + self.state.set_invalid_move("Cannot move outside bounds.") + game_state["invalid_move_reason"] = "Cannot move outside bounds." + game_state["is_terminal"] = True + return f"{player} attempted to move outside bounds." + if maze[new_x][new_y] == "#": + self.state.set_invalid_move("Cannot move through wall or outside bounds.") + game_state["invalid_move_reason"] = "Cannot move through wall." + game_state["is_terminal"] = True + return f"{player} tried to move into a wall." + + game_state["players"][player]["position"] = [new_x, new_y] + game_state["players"][player]["focus"] -= 1 + return f"{player} moved {direction} to {(new_x, new_y)}." + + def _process_scan(self, player: str, game_state: Dict[str, Any]) -> str: + pos = game_state["players"][player]["position"] + maze = game_state["maze_layout"] + dirs = { + "North": (pos[0] - 1, pos[1]), + "South": (pos[0] + 1, pos[1]), + "East": (pos[0], pos[1] + 1), + "West": (pos[0], pos[1] - 1), + } + result = {} + for dir_name, (x, y) in dirs.items(): + if 0 <= x < len(maze) and 0 <= y < len(maze[0]): + result[dir_name] = "Wall" if maze[x][y] == "#" else "Open" + else: + result[dir_name] = "Out of bounds" + obs_msg = ", ".join(f"{k}: {v}" for k, v in result.items()) + return f"{player} scanned surroundings. {obs_msg}" + + def _process_mark(self, player: str, game_state: Dict[str, Any]) -> str: + pos = game_state["players"][player]["position"] + markers = game_state["players"][player]["markers"] + if pos not in markers: + markers.append(pos.copy()) + return f"{player} marked the cell at {tuple(pos)}." + + def _process_rest(self, player: str, game_state: Dict[str, Any]) -> str: + game_state["players"][player]["focus"] += 1 + return f"{player} rested and recovered 1 Focus (now {game_state['players'][player]['focus']})." + + # ---------------------------------------------------------------------- + # Prompt + # ---------------------------------------------------------------------- + def _generate_player_prompt(self, player_id: int, game_state: Dict[str, Any]) -> str: + """ + Generates player prompt at start of game. + """ + player_name = "Sun" if player_id == 0 else "Moon" + pos = tuple(game_state["players"][player_name]["position"]) + focus = game_state["players"][player_name]["focus"] + intro = ( + f"You are **Player {player_name}**, an explorer within the mystic underground labyrinth of EchoMaze.\n" + f"Your current position is {pos} with Focus = {focus}.\n" + "Your objective is to reach the Exit Glyph before your rival.\n" + "Actions must be exactly one of:\n" + " - [Move: North], [Move: South], [Move: East], [Move: West]\n" + " - [Scan] — Reveal walls around you.\n" + " - [Mark] — Leave a marker in this cell.\n" + " - [Rest] — Skip turn, regain 1 Focus.\n\n" + "Only one action per turn. Place it inside \\boxed{} like so:\n" + "Example valid response:\n" + "I decide to move north.\n" + "\\boxed{[Move: North]}\n\n" + "Example invalid response:\n" + "I will move upward.\n" + "\\boxed{[Move: Up]} <-- invalid action\n" + ) + return intro + + # ---------------------------------------------------------------------- + # Framework helpers + # ---------------------------------------------------------------------- + def get_observation(self) -> Tuple[int, List]: + """Return observation for current player""" + return self.state.current_player_id, self.state.game_state + + def close(self) -> Tuple[Dict, Dict]: + """Return final info""" + return self.state.rewards, self.state.game_state +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..83a1603 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +# pyproject.toml + +[project] +name = "game_20251122_025522" +version = "0.1.0" +description = "EchoMaze: The Labyrinth Duel environment generated for TextArena." +dependencies = [ + "textarena>=0.7.3" +] + +[openverse] +entry_point = "env:EchoMazeEnv" +tags = ["openverse", "generated"] +author = "Openverse"