ziadbc commited on
Commit
9b43224
·
verified ·
1 Parent(s): 060d4fa

Upload folder using huggingface_hub

Browse files
teeunit_env/__init__.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2024 TeeUnit Project
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ TeeUnit OpenEnv Environment
6
+
7
+ An OpenEnv-compatible multi-agent arena environment wrapping the real Teeworlds 0.7.5 game
8
+ for LLM-based reinforcement learning training.
9
+
10
+ Example:
11
+ >>> from teeunit_env import TeeEnv
12
+ >>>
13
+ >>> with TeeEnv(base_url="http://localhost:8000") as env:
14
+ ... env.reset()
15
+ ... tools = env.list_tools()
16
+ ... print([t.name for t in tools])
17
+ ... # ['move', 'jump', 'aim', 'shoot', 'hook', 'get_status']
18
+ ...
19
+ ... # Get current game state
20
+ ... status = env.call_tool("get_status")
21
+ ... print(status)
22
+ ...
23
+ ... # Take actions
24
+ ... env.call_tool("move", direction="right")
25
+ ... env.call_tool("aim", x=500, y=300)
26
+ ... env.call_tool("shoot", weapon=1)
27
+ """
28
+
29
+ from .client import TeeEnv, TeeAction
30
+
31
+ __all__ = ["TeeEnv", "TeeAction"]
32
+ __version__ = "0.1.0"
teeunit_env/client.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2024 TeeUnit Project
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ TeeUnit Environment Client.
6
+
7
+ This module provides the client for connecting to a TeeUnit Environment server.
8
+ TeeEnv extends MCPToolClient to provide tool-calling style interactions.
9
+
10
+ Example:
11
+ >>> with TeeEnv(base_url="http://localhost:8000") as env:
12
+ ... env.reset()
13
+ ...
14
+ ... # Discover tools
15
+ ... tools = env.list_tools()
16
+ ... print([t.name for t in tools]) # ['move', 'jump', 'aim', 'shoot', 'hook', 'get_status']
17
+ ...
18
+ ... # Get game state
19
+ ... status = env.call_tool("get_status")
20
+ ... print(status)
21
+ ...
22
+ ... # Take actions
23
+ ... result = env.call_tool("move", direction="right")
24
+ ... result = env.call_tool("shoot", weapon=2)
25
+ """
26
+
27
+ from dataclasses import dataclass
28
+ from typing import Optional, Any, Dict
29
+
30
+ # Support both in-repo and standalone imports
31
+ try:
32
+ from openenv.core.mcp_client import MCPToolClient
33
+ except ImportError:
34
+ # Fallback for development/testing without openenv installed
35
+ class MCPToolClient:
36
+ """Fallback MCPToolClient for development."""
37
+
38
+ def __init__(self, base_url: str = "http://localhost:8000", **kwargs):
39
+ self.base_url = base_url
40
+ self._connected = False
41
+
42
+ def __enter__(self):
43
+ self._connected = True
44
+ return self
45
+
46
+ def __exit__(self, *args):
47
+ self._connected = False
48
+
49
+ def reset(self, **kwargs):
50
+ pass
51
+
52
+ def list_tools(self):
53
+ return []
54
+
55
+ def call_tool(self, name: str, **kwargs):
56
+ return None
57
+
58
+ def step(self, action):
59
+ return None
60
+
61
+ def close(self):
62
+ self._connected = False
63
+
64
+ @classmethod
65
+ def from_docker_image(cls, image: str, **kwargs):
66
+ return cls(**kwargs)
67
+
68
+ @classmethod
69
+ def from_env(cls, env_id: str, **kwargs):
70
+ return cls(**kwargs)
71
+
72
+
73
+ @dataclass
74
+ class TeeAction:
75
+ """
76
+ Action for the TeeUnit environment.
77
+
78
+ This is a convenience class for building actions to send to the environment.
79
+ The actual actions are sent via MCP tools (move, jump, aim, shoot, hook).
80
+
81
+ Attributes:
82
+ tool_name: The MCP tool to call
83
+ arguments: Arguments for the tool
84
+ """
85
+ tool_name: str
86
+ arguments: Dict[str, Any]
87
+
88
+ @classmethod
89
+ def move(cls, direction: str = "none") -> "TeeAction":
90
+ """Create a move action."""
91
+ return cls(tool_name="move", arguments={"direction": direction})
92
+
93
+ @classmethod
94
+ def jump(cls) -> "TeeAction":
95
+ """Create a jump action."""
96
+ return cls(tool_name="jump", arguments={})
97
+
98
+ @classmethod
99
+ def aim(cls, x: int, y: int) -> "TeeAction":
100
+ """Create an aim action."""
101
+ return cls(tool_name="aim", arguments={"x": x, "y": y})
102
+
103
+ @classmethod
104
+ def shoot(cls, weapon: int = -1) -> "TeeAction":
105
+ """Create a shoot action."""
106
+ return cls(tool_name="shoot", arguments={"weapon": weapon})
107
+
108
+ @classmethod
109
+ def hook(cls) -> "TeeAction":
110
+ """Create a hook action."""
111
+ return cls(tool_name="hook", arguments={})
112
+
113
+ @classmethod
114
+ def get_status(cls) -> "TeeAction":
115
+ """Create a get_status action."""
116
+ return cls(tool_name="get_status", arguments={})
117
+
118
+
119
+ class TeeEnv(MCPToolClient):
120
+ """
121
+ Client for the TeeUnit Environment.
122
+
123
+ This client provides a simple interface for interacting with the TeeUnit
124
+ Environment via MCP tools. It inherits all functionality from MCPToolClient:
125
+ - `list_tools()`: Discover available tools
126
+ - `call_tool(name, **kwargs)`: Call a tool by name
127
+ - `reset(**kwargs)`: Reset the environment
128
+ - `step(action)`: Execute an action
129
+
130
+ Available MCP Tools:
131
+ - `move(direction)`: Move left, right, or none
132
+ - `jump()`: Make the tee jump
133
+ - `aim(x, y)`: Aim at coordinates
134
+ - `shoot(weapon)`: Fire weapon (0-5 or -1 for current)
135
+ - `hook()`: Use grappling hook
136
+ - `get_status()`: Get game state as text
137
+
138
+ Example:
139
+ >>> # Connect to a running server
140
+ >>> with TeeEnv(base_url="http://localhost:8000") as env:
141
+ ... env.reset()
142
+ ...
143
+ ... # List available tools
144
+ ... tools = env.list_tools()
145
+ ... for tool in tools:
146
+ ... print(f"{tool.name}: {tool.description}")
147
+ ...
148
+ ... # Get game state
149
+ ... status = env.call_tool("get_status")
150
+ ... print(status)
151
+ ...
152
+ ... # Take actions
153
+ ... env.call_tool("move", direction="right")
154
+ ... env.call_tool("aim", x=500, y=300)
155
+ ... env.call_tool("shoot", weapon=2)
156
+
157
+ Example with HuggingFace Space:
158
+ >>> # Connect to HuggingFace Space
159
+ >>> env = TeeEnv.from_env("ziadbc/teeunit-env")
160
+ >>> try:
161
+ ... env.reset()
162
+ ... status = env.call_tool("get_status")
163
+ ... print(status)
164
+ ... finally:
165
+ ... env.close()
166
+
167
+ Example with Docker:
168
+ >>> # Automatically start container and connect
169
+ >>> env = TeeEnv.from_docker_image("teeunit-env:latest")
170
+ >>> try:
171
+ ... env.reset()
172
+ ... env.call_tool("move", direction="right")
173
+ ... finally:
174
+ ... env.close()
175
+ """
176
+
177
+ def execute_action(self, action: TeeAction) -> Any:
178
+ """
179
+ Execute a TeeAction.
180
+
181
+ Args:
182
+ action: TeeAction to execute
183
+
184
+ Returns:
185
+ Result from the MCP tool call
186
+ """
187
+ return self.call_tool(action.tool_name, **action.arguments)
188
+
189
+ def get_status(self) -> str:
190
+ """
191
+ Get the current game state as text.
192
+
193
+ Returns:
194
+ Text description of game state
195
+ """
196
+ return self.call_tool("get_status")
197
+
198
+ def move(self, direction: str = "none") -> str:
199
+ """
200
+ Move the tee.
201
+
202
+ Args:
203
+ direction: "left", "right", or "none"
204
+
205
+ Returns:
206
+ Result message
207
+ """
208
+ return self.call_tool("move", direction=direction)
209
+
210
+ def jump(self) -> str:
211
+ """
212
+ Make the tee jump.
213
+
214
+ Returns:
215
+ Result message
216
+ """
217
+ return self.call_tool("jump")
218
+
219
+ def aim(self, x: int, y: int) -> str:
220
+ """
221
+ Aim at coordinates.
222
+
223
+ Args:
224
+ x: Target X coordinate
225
+ y: Target Y coordinate
226
+
227
+ Returns:
228
+ Result message
229
+ """
230
+ return self.call_tool("aim", x=x, y=y)
231
+
232
+ def shoot(self, weapon: int = -1) -> str:
233
+ """
234
+ Fire weapon.
235
+
236
+ Args:
237
+ weapon: Weapon ID (0-5) or -1 for current weapon
238
+
239
+ Returns:
240
+ Result message
241
+ """
242
+ return self.call_tool("shoot", weapon=weapon)
243
+
244
+ def hook(self) -> str:
245
+ """
246
+ Use grappling hook.
247
+
248
+ Returns:
249
+ Result message
250
+ """
251
+ return self.call_tool("hook")
teeunit_env/openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: teeunit_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
teeunit_env/pyproject.toml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "teeunit-env"
3
+ version = "0.1.0"
4
+ description = "OpenEnv-compatible Teeworlds arena environment for LLM RL training"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "TeeUnit Team", email = "ziadbc@users.noreply.github.com" }
9
+ ]
10
+ requires-python = ">=3.10"
11
+ keywords = ["openenv", "teeworlds", "reinforcement-learning", "llm", "mcp"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Science/Research",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+
23
+ dependencies = [
24
+ "openenv-core>=0.2.1",
25
+ "fastmcp>=0.1.0",
26
+ "fastapi>=0.100.0",
27
+ "uvicorn>=0.23.0",
28
+ "pydantic>=2.0.0",
29
+ "websockets>=11.0",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest>=7.0",
35
+ "pytest-asyncio>=0.21",
36
+ "httpx>=0.24",
37
+ ]
38
+
39
+ [project.scripts]
40
+ teeunit-server = "teeunit_env.server.app:main"
41
+
42
+ [build-system]
43
+ requires = ["hatchling"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["teeunit_env"]
teeunit_env/server/Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TeeUnit OpenEnv Environment
2
+ # Compatible with HuggingFace Spaces
3
+
4
+ FROM python:3.11-slim
5
+
6
+ WORKDIR /app
7
+
8
+ # Install system dependencies
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy environment files
14
+ COPY teeunit_env/ ./teeunit_env/
15
+ COPY teeunit_env/pyproject.toml ./
16
+
17
+ # Install Python dependencies
18
+ RUN pip install --no-cache-dir \
19
+ openenv-core>=0.2.1 \
20
+ fastmcp>=0.1.0 \
21
+ fastapi>=0.100.0 \
22
+ uvicorn>=0.23.0 \
23
+ pydantic>=2.0.0 \
24
+ websockets>=11.0
25
+
26
+ # Expose port
27
+ EXPOSE 8000
28
+
29
+ # Set environment variables
30
+ ENV PYTHONPATH=/app
31
+ ENV PYTHONUNBUFFERED=1
32
+
33
+ # Health check
34
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
35
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
36
+
37
+ # Run the server
38
+ CMD ["uvicorn", "teeunit_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
teeunit_env/server/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2024 TeeUnit Project
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ TeeUnit OpenEnv Server
6
+
7
+ This module provides the server-side implementation of the TeeUnit environment,
8
+ exposing Teeworlds game control through MCP tools.
9
+ """
10
+
11
+ from .tee_environment import TeeEnvironment
12
+
13
+ __all__ = ["TeeEnvironment"]
teeunit_env/server/app.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2024 TeeUnit Project
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ FastAPI application for the TeeUnit Environment.
6
+
7
+ This module creates an HTTP server that exposes the TeeEnvironment
8
+ over HTTP and WebSocket endpoints, compatible with MCPToolClient.
9
+
10
+ Usage:
11
+ # Development (with auto-reload):
12
+ uvicorn teeunit_env.server.app:app --reload --host 0.0.0.0 --port 8000
13
+
14
+ # Production:
15
+ uvicorn teeunit_env.server.app:app --host 0.0.0.0 --port 8000 --workers 4
16
+
17
+ # Or run directly:
18
+ python -m teeunit_env.server.app
19
+ """
20
+
21
+ # Support both in-repo and standalone imports
22
+ try:
23
+ from openenv.core.env_server.http_server import create_app
24
+ from openenv.core.env_server.mcp_types import CallToolAction, CallToolObservation
25
+
26
+ from .tee_environment import TeeEnvironment
27
+
28
+ # Create the app with web interface
29
+ # Pass the class (factory) instead of an instance for WebSocket session support
30
+ # Use MCP types for action/observation since this is a pure MCP environment
31
+ app = create_app(
32
+ TeeEnvironment, CallToolAction, CallToolObservation, env_name="teeunit_env"
33
+ )
34
+
35
+ except ImportError:
36
+ # Fallback: Create a simple FastAPI app for development/testing
37
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
38
+ from fastapi.middleware.cors import CORSMiddleware
39
+ from pydantic import BaseModel
40
+ from typing import Optional, Dict, Any
41
+ import json
42
+
43
+ # Import our environment
44
+ try:
45
+ from .tee_environment import TeeEnvironment
46
+ except ImportError:
47
+ from tee_environment import TeeEnvironment
48
+
49
+ app = FastAPI(
50
+ title="TeeUnit OpenEnv",
51
+ description="OpenEnv-compatible Teeworlds arena environment for LLM RL training",
52
+ version="0.1.0",
53
+ )
54
+
55
+ # Add CORS middleware
56
+ app.add_middleware(
57
+ CORSMiddleware,
58
+ allow_origins=["*"],
59
+ allow_credentials=True,
60
+ allow_methods=["*"],
61
+ allow_headers=["*"],
62
+ )
63
+
64
+ # Store environment instances per session
65
+ _environments: Dict[str, TeeEnvironment] = {}
66
+
67
+ class ResetRequest(BaseModel):
68
+ seed: Optional[int] = None
69
+ episode_id: Optional[str] = None
70
+
71
+ class ResetResponse(BaseModel):
72
+ status: str
73
+ episode_id: str
74
+ message: str
75
+
76
+ class ToolCallRequest(BaseModel):
77
+ tool_name: str
78
+ arguments: Dict[str, Any] = {}
79
+
80
+ class ToolCallResponse(BaseModel):
81
+ result: Any
82
+ reward: float
83
+ done: bool
84
+ metadata: Dict[str, Any] = {}
85
+
86
+ class ToolInfo(BaseModel):
87
+ name: str
88
+ description: str
89
+ parameters: Dict[str, Any] = {}
90
+
91
+ @app.get("/")
92
+ async def root():
93
+ """Root endpoint with environment info."""
94
+ return {
95
+ "name": "TeeUnit OpenEnv",
96
+ "version": "0.1.0",
97
+ "description": "OpenEnv-compatible Teeworlds arena for LLM training",
98
+ "endpoints": {
99
+ "reset": "POST /reset",
100
+ "tools": "GET /tools",
101
+ "call_tool": "POST /call_tool",
102
+ "websocket": "WS /ws",
103
+ }
104
+ }
105
+
106
+ @app.get("/health")
107
+ async def health():
108
+ """Health check endpoint."""
109
+ return {"status": "healthy"}
110
+
111
+ @app.post("/reset", response_model=ResetResponse)
112
+ async def reset(request: ResetRequest):
113
+ """Reset the environment for a new episode."""
114
+ env = TeeEnvironment()
115
+ obs = env.reset(seed=request.seed, episode_id=request.episode_id)
116
+
117
+ session_id = obs.metadata.get("episode_id", "default")
118
+ _environments[session_id] = env
119
+
120
+ return ResetResponse(
121
+ status="ready",
122
+ episode_id=session_id,
123
+ message=obs.metadata.get("message", "Environment ready"),
124
+ )
125
+
126
+ @app.get("/tools")
127
+ async def list_tools():
128
+ """List available MCP tools."""
129
+ return {
130
+ "tools": [
131
+ {
132
+ "name": "move",
133
+ "description": "Move the tee left, right, or none",
134
+ "parameters": {"direction": {"type": "string", "enum": ["left", "right", "none"]}},
135
+ },
136
+ {
137
+ "name": "jump",
138
+ "description": "Make the tee jump",
139
+ "parameters": {},
140
+ },
141
+ {
142
+ "name": "aim",
143
+ "description": "Aim at target coordinates",
144
+ "parameters": {
145
+ "x": {"type": "integer", "description": "Target X coordinate"},
146
+ "y": {"type": "integer", "description": "Target Y coordinate"},
147
+ },
148
+ },
149
+ {
150
+ "name": "shoot",
151
+ "description": "Fire the specified weapon",
152
+ "parameters": {
153
+ "weapon": {"type": "integer", "description": "Weapon ID (0-5) or -1 for current", "default": -1},
154
+ },
155
+ },
156
+ {
157
+ "name": "hook",
158
+ "description": "Use the grappling hook",
159
+ "parameters": {},
160
+ },
161
+ {
162
+ "name": "get_status",
163
+ "description": "Get current game state as text",
164
+ "parameters": {},
165
+ },
166
+ ]
167
+ }
168
+
169
+ @app.post("/call_tool", response_model=ToolCallResponse)
170
+ async def call_tool(request: ToolCallRequest, session_id: str = "default"):
171
+ """Call an MCP tool."""
172
+ env = _environments.get(session_id)
173
+ if env is None:
174
+ env = TeeEnvironment()
175
+ env.reset()
176
+ _environments[session_id] = env
177
+
178
+ # Get the MCP server from environment
179
+ mcp = env._mcp
180
+
181
+ # Call the tool
182
+ tool_name = request.tool_name
183
+ arguments = request.arguments
184
+
185
+ try:
186
+ # Use FastMCP's async call_tool method
187
+ tool_result = await mcp.call_tool(tool_name, arguments)
188
+
189
+ # Extract text result from ToolResult
190
+ if tool_result and tool_result.content:
191
+ result = tool_result.content[0].text if hasattr(tool_result.content[0], 'text') else str(tool_result.content[0])
192
+ else:
193
+ result = str(tool_result)
194
+
195
+ # Simulate tick and get reward
196
+ env._simulate_tick()
197
+ reward = env._calculate_reward()
198
+
199
+ # Check done
200
+ done = env._state.step_count >= env._max_steps
201
+ player = env._agents.get(env._current_agent_id)
202
+ if player and not player.is_alive:
203
+ done = True
204
+
205
+ return ToolCallResponse(
206
+ result=result,
207
+ reward=reward,
208
+ done=done,
209
+ metadata={
210
+ "step": env._state.step_count,
211
+ "tick": env._tick,
212
+ },
213
+ )
214
+ except Exception as e:
215
+ return ToolCallResponse(
216
+ result=f"Error: {str(e)}",
217
+ reward=0.0,
218
+ done=False,
219
+ metadata={"error": str(e)},
220
+ )
221
+
222
+ @app.websocket("/ws")
223
+ async def websocket_endpoint(websocket: WebSocket):
224
+ """WebSocket endpoint for real-time interaction."""
225
+ await websocket.accept()
226
+
227
+ env = TeeEnvironment()
228
+ env.reset()
229
+
230
+ try:
231
+ while True:
232
+ data = await websocket.receive_text()
233
+ message = json.loads(data)
234
+
235
+ action_type = message.get("type", "call_tool")
236
+
237
+ if action_type == "reset":
238
+ obs = env.reset(
239
+ seed=message.get("seed"),
240
+ episode_id=message.get("episode_id"),
241
+ )
242
+ await websocket.send_json({
243
+ "type": "reset",
244
+ "status": "ready",
245
+ "episode_id": obs.metadata.get("episode_id"),
246
+ "message": obs.metadata.get("message"),
247
+ })
248
+
249
+ elif action_type == "list_tools":
250
+ await websocket.send_json({
251
+ "type": "tools",
252
+ "tools": [
253
+ {"name": "move", "description": "Move left/right/none"},
254
+ {"name": "jump", "description": "Jump"},
255
+ {"name": "aim", "description": "Aim at x,y"},
256
+ {"name": "shoot", "description": "Fire weapon"},
257
+ {"name": "hook", "description": "Use hook"},
258
+ {"name": "get_status", "description": "Get game state"},
259
+ ],
260
+ })
261
+
262
+ elif action_type == "call_tool":
263
+ tool_name = message.get("tool_name")
264
+ arguments = message.get("arguments", {})
265
+
266
+ # Call tool using FastMCP's async call_tool method
267
+ mcp = env._mcp
268
+ try:
269
+ tool_result = await mcp.call_tool(tool_name, arguments)
270
+ if tool_result and tool_result.content:
271
+ result = tool_result.content[0].text if hasattr(tool_result.content[0], 'text') else str(tool_result.content[0])
272
+ else:
273
+ result = str(tool_result)
274
+ except Exception as e:
275
+ result = f"Error: {str(e)}"
276
+
277
+ # Simulate and get reward
278
+ env._simulate_tick()
279
+ reward = env._calculate_reward()
280
+ done = env._state.step_count >= env._max_steps
281
+
282
+ await websocket.send_json({
283
+ "type": "tool_result",
284
+ "tool_name": tool_name,
285
+ "result": result,
286
+ "reward": reward,
287
+ "done": done,
288
+ "step": env._state.step_count,
289
+ "tick": env._tick,
290
+ })
291
+
292
+ except WebSocketDisconnect:
293
+ pass
294
+
295
+
296
+ def main():
297
+ """
298
+ Entry point for direct execution.
299
+
300
+ Usage:
301
+ python -m teeunit_env.server.app
302
+ uvicorn teeunit_env.server.app:app --host 0.0.0.0 --port 8000
303
+ """
304
+ import uvicorn
305
+ uvicorn.run(app, host="0.0.0.0", port=8000)
306
+
307
+
308
+ if __name__ == "__main__":
309
+ main()
teeunit_env/server/tee_environment.py ADDED
@@ -0,0 +1,682 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2024 TeeUnit Project
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """
5
+ TeeUnit Environment Implementation.
6
+
7
+ A MCP environment that wraps the Teeworlds game for LLM-based RL training.
8
+ All interactions happen through MCP tools that translate to game actions.
9
+
10
+ MCP Tools:
11
+ - `move(direction)`: Move the tee left, right, or none
12
+ - `jump()`: Make the tee jump
13
+ - `aim(x, y)`: Aim at target coordinates
14
+ - `shoot(weapon)`: Fire the specified weapon
15
+ - `hook()`: Use the grappling hook
16
+ - `get_status()`: Get current game state as text
17
+
18
+ Example:
19
+ >>> from openenv.core.env_server.mcp_types import ListToolsAction, CallToolAction
20
+ >>> env = TeeEnvironment()
21
+ >>> env.reset()
22
+ >>>
23
+ >>> # List available tools
24
+ >>> obs = env.step(ListToolsAction())
25
+ >>> print([t.name for t in obs.tools]) # ["move", "jump", "aim", "shoot", "hook", "get_status"]
26
+ >>>
27
+ >>> # Get game state
28
+ >>> obs = env.step(CallToolAction(tool_name="get_status", arguments={}))
29
+ >>> print(obs.result)
30
+ """
31
+
32
+ from typing import Any, Optional, Dict, List
33
+ from uuid import uuid4
34
+ import random
35
+ import math
36
+
37
+ # Support both in-repo and standalone imports
38
+ try:
39
+ from openenv.core.env_server.mcp_environment import MCPEnvironment
40
+ from openenv.core.env_server.types import Action, Observation, State
41
+ except ImportError:
42
+ # Fallback for development/testing
43
+ from dataclasses import dataclass
44
+
45
+ @dataclass
46
+ class State:
47
+ episode_id: str = ""
48
+ step_count: int = 0
49
+
50
+ @dataclass
51
+ class Observation:
52
+ done: bool = False
53
+ reward: float = 0.0
54
+ metadata: dict = None
55
+
56
+ def __post_init__(self):
57
+ if self.metadata is None:
58
+ self.metadata = {}
59
+
60
+ class Action:
61
+ pass
62
+
63
+ class MCPEnvironment:
64
+ def __init__(self, mcp):
65
+ self._mcp = mcp
66
+
67
+ def step(self, action, **kwargs):
68
+ return Observation()
69
+
70
+ from fastmcp import FastMCP
71
+
72
+
73
+ # Weapon definitions
74
+ WEAPONS = {
75
+ 0: {"name": "hammer", "ammo": -1, "damage": 3},
76
+ 1: {"name": "pistol", "ammo": 10, "damage": 1},
77
+ 2: {"name": "shotgun", "ammo": 10, "damage": 3},
78
+ 3: {"name": "grenade", "ammo": 10, "damage": 6},
79
+ 4: {"name": "laser", "ammo": 10, "damage": 5},
80
+ 5: {"name": "ninja", "ammo": -1, "damage": 9},
81
+ }
82
+
83
+
84
+ class GameAgent:
85
+ """Represents a player/bot in the game."""
86
+
87
+ def __init__(self, agent_id: int):
88
+ self.agent_id = agent_id
89
+ self.x = 400.0 + random.uniform(-200, 200)
90
+ self.y = 300.0 + random.uniform(-100, 100)
91
+ self.vel_x = 0.0
92
+ self.vel_y = 0.0
93
+ self.health = 10
94
+ self.armor = 0
95
+ self.weapon = 1 # pistol
96
+ self.ammo = {w: WEAPONS[w]["ammo"] for w in WEAPONS}
97
+ self.direction = 1 # 1 = right, -1 = left
98
+ self.is_alive = True
99
+ self.score = 0
100
+ self.aim_x = self.x + 100
101
+ self.aim_y = self.y
102
+ self.is_hooking = False
103
+ self.is_grounded = True
104
+
105
+ def respawn(self):
106
+ """Respawn at random location."""
107
+ self.x = 400.0 + random.uniform(-200, 200)
108
+ self.y = 300.0 + random.uniform(-100, 100)
109
+ self.vel_x = 0.0
110
+ self.vel_y = 0.0
111
+ self.health = 10
112
+ self.armor = 0
113
+ self.weapon = 1
114
+ self.ammo = {w: WEAPONS[w]["ammo"] for w in WEAPONS}
115
+ self.is_alive = True
116
+ self.is_hooking = False
117
+
118
+
119
+ class TeeEnvironment(MCPEnvironment):
120
+ """
121
+ OpenEnv-compatible Teeworlds environment with MCP tool interface.
122
+
123
+ This environment provides a text-based interface for LLM agents to play
124
+ Teeworlds. The LLM receives game state as natural language descriptions
125
+ and issues commands through MCP tools.
126
+
127
+ For hackathon demo, this uses a simplified game simulation. For production,
128
+ it can be connected to the real Teeworlds server via bot_manager.
129
+
130
+ Example:
131
+ >>> with TeeEnv(base_url="http://localhost:8000") as env:
132
+ ... env.reset()
133
+ ... status = env.call_tool("get_status")
134
+ ... env.call_tool("move", direction="right")
135
+ ... env.call_tool("shoot", weapon=2)
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ num_agents: int = 4,
141
+ max_steps: int = 1000,
142
+ use_real_server: bool = False,
143
+ server_host: str = "127.0.0.1",
144
+ server_port: int = 8303,
145
+ ):
146
+ """
147
+ Initialize the TeeUnit environment.
148
+
149
+ Args:
150
+ num_agents: Number of agents in the arena
151
+ max_steps: Maximum steps per episode
152
+ use_real_server: If True, connect to real Teeworlds server
153
+ server_host: Teeworlds server host
154
+ server_port: Teeworlds server port
155
+ """
156
+ # Create MCP server and define tools inline
157
+ mcp = FastMCP("teeunit_env")
158
+
159
+ # Store config
160
+ self._num_agents = num_agents
161
+ self._max_steps = max_steps
162
+ self._use_real_server = use_real_server
163
+ self._server_host = server_host
164
+ self._server_port = server_port
165
+
166
+ # Game state
167
+ self._agents: Dict[int, GameAgent] = {}
168
+ self._tick = 0
169
+ self._kill_events: List[dict] = []
170
+ self._current_agent_id = 0 # LLM controls agent 0
171
+
172
+ # Episode state
173
+ self._state = State(episode_id=str(uuid4()), step_count=0)
174
+
175
+ # Real server connection (optional)
176
+ self._bot_manager = None
177
+
178
+ # Define MCP tools
179
+ @mcp.tool
180
+ def move(direction: str) -> str:
181
+ """
182
+ Move the tee horizontally.
183
+
184
+ Args:
185
+ direction: "left", "right", or "none"
186
+
187
+ Returns:
188
+ Result message describing the action taken
189
+ """
190
+ agent = self._agents.get(self._current_agent_id)
191
+ if not agent or not agent.is_alive:
192
+ return "Cannot move: agent is dead"
193
+
194
+ if direction == "left":
195
+ agent.direction = -1
196
+ agent.vel_x = max(agent.vel_x - 5, -15)
197
+ return f"Moving left. Velocity: ({agent.vel_x:.1f}, {agent.vel_y:.1f})"
198
+ elif direction == "right":
199
+ agent.direction = 1
200
+ agent.vel_x = min(agent.vel_x + 5, 15)
201
+ return f"Moving right. Velocity: ({agent.vel_x:.1f}, {agent.vel_y:.1f})"
202
+ else:
203
+ agent.vel_x *= 0.8 # friction
204
+ return f"Stopped. Velocity: ({agent.vel_x:.1f}, {agent.vel_y:.1f})"
205
+
206
+ @mcp.tool
207
+ def jump() -> str:
208
+ """
209
+ Make the tee jump. Can double-jump in the air.
210
+
211
+ Returns:
212
+ Result message describing the jump
213
+ """
214
+ agent = self._agents.get(self._current_agent_id)
215
+ if not agent or not agent.is_alive:
216
+ return "Cannot jump: agent is dead"
217
+
218
+ if agent.is_grounded:
219
+ agent.vel_y = -12
220
+ agent.is_grounded = False
221
+ return f"Jumped! Velocity: ({agent.vel_x:.1f}, {agent.vel_y:.1f})"
222
+ else:
223
+ # Air jump (weaker)
224
+ agent.vel_y = -8
225
+ return f"Air jumped! Velocity: ({agent.vel_x:.1f}, {agent.vel_y:.1f})"
226
+
227
+ @mcp.tool
228
+ def aim(x: int, y: int) -> str:
229
+ """
230
+ Aim at target coordinates.
231
+
232
+ Args:
233
+ x: Target X coordinate
234
+ y: Target Y coordinate
235
+
236
+ Returns:
237
+ Result message confirming aim direction
238
+ """
239
+ agent = self._agents.get(self._current_agent_id)
240
+ if not agent or not agent.is_alive:
241
+ return "Cannot aim: agent is dead"
242
+
243
+ agent.aim_x = x
244
+ agent.aim_y = y
245
+
246
+ # Calculate angle for display
247
+ dx = x - agent.x
248
+ dy = y - agent.y
249
+ angle = math.atan2(dy, dx) * 180 / math.pi
250
+ distance = math.sqrt(dx*dx + dy*dy)
251
+
252
+ return f"Aiming at ({x}, {y}). Angle: {angle:.1f} deg, Distance: {distance:.1f} units"
253
+
254
+ @mcp.tool
255
+ def shoot(weapon: int = -1) -> str:
256
+ """
257
+ Fire the current or specified weapon.
258
+
259
+ Args:
260
+ weapon: Weapon ID (0=hammer, 1=pistol, 2=shotgun, 3=grenade, 4=laser, 5=ninja).
261
+ Use -1 for current weapon.
262
+
263
+ Returns:
264
+ Result message describing the shot and any hits
265
+ """
266
+ agent = self._agents.get(self._current_agent_id)
267
+ if not agent or not agent.is_alive:
268
+ return "Cannot shoot: agent is dead"
269
+
270
+ # Switch weapon if specified
271
+ if weapon >= 0 and weapon <= 5:
272
+ agent.weapon = weapon
273
+
274
+ wpn = WEAPONS[agent.weapon]
275
+ wpn_name = wpn["name"]
276
+
277
+ # Check ammo
278
+ if wpn["ammo"] > 0 and agent.ammo[agent.weapon] <= 0:
279
+ return f"Out of ammo for {wpn_name}!"
280
+
281
+ # Use ammo
282
+ if wpn["ammo"] > 0:
283
+ agent.ammo[agent.weapon] -= 1
284
+
285
+ # Check for hits on other agents
286
+ hits = []
287
+ for other_id, other in self._agents.items():
288
+ if other_id == self._current_agent_id or not other.is_alive:
289
+ continue
290
+
291
+ # Simple hit detection based on aim
292
+ dx = other.x - agent.x
293
+ dy = other.y - agent.y
294
+ distance = math.sqrt(dx*dx + dy*dy)
295
+
296
+ aim_dx = agent.aim_x - agent.x
297
+ aim_dy = agent.aim_y - agent.y
298
+ aim_dist = math.sqrt(aim_dx*aim_dx + aim_dy*aim_dy)
299
+
300
+ if aim_dist > 0:
301
+ # Check if enemy is roughly in line of fire
302
+ dot = (dx * aim_dx + dy * aim_dy) / (aim_dist * max(distance, 1))
303
+
304
+ # Hit probability based on weapon and distance
305
+ hit_range = 400 if agent.weapon != 0 else 50 # hammer short range
306
+ if distance < hit_range and dot > 0.8:
307
+ # Hit!
308
+ damage = wpn["damage"]
309
+ other.health -= damage
310
+ other.armor = max(0, other.armor - damage // 2)
311
+
312
+ if other.health <= 0:
313
+ other.is_alive = False
314
+ agent.score += 1
315
+ self._kill_events.append({
316
+ "killer_id": self._current_agent_id,
317
+ "victim_id": other_id,
318
+ "weapon": agent.weapon,
319
+ "tick": self._tick,
320
+ })
321
+ hits.append(f"KILLED Player {other_id} with {wpn_name}!")
322
+ else:
323
+ hits.append(f"Hit Player {other_id} for {damage} damage ({other.health}HP remaining)")
324
+
325
+ ammo_str = f"({agent.ammo[agent.weapon]} ammo)" if wpn["ammo"] > 0 else ""
326
+ if hits:
327
+ return f"Fired {wpn_name} {ammo_str}. " + " ".join(hits)
328
+ else:
329
+ return f"Fired {wpn_name} {ammo_str}. No hits."
330
+
331
+ @mcp.tool
332
+ def hook() -> str:
333
+ """
334
+ Use the grappling hook in the aim direction.
335
+ The hook can grab walls or enemies to pull yourself toward them.
336
+
337
+ Returns:
338
+ Result message describing hook action
339
+ """
340
+ agent = self._agents.get(self._current_agent_id)
341
+ if not agent or not agent.is_alive:
342
+ return "Cannot hook: agent is dead"
343
+
344
+ agent.is_hooking = not agent.is_hooking
345
+
346
+ if agent.is_hooking:
347
+ # Pull toward aim point
348
+ dx = agent.aim_x - agent.x
349
+ dy = agent.aim_y - agent.y
350
+ dist = math.sqrt(dx*dx + dy*dy)
351
+ if dist > 0:
352
+ agent.vel_x += (dx / dist) * 3
353
+ agent.vel_y += (dy / dist) * 3
354
+ return f"Hook deployed! Pulling toward ({agent.aim_x}, {agent.aim_y})"
355
+ else:
356
+ return "Hook released."
357
+
358
+ @mcp.tool
359
+ def get_status() -> str:
360
+ """
361
+ Get the current game state as a text description.
362
+
363
+ Returns:
364
+ Detailed text description of current game state including:
365
+ - Your position, health, weapon, ammo
366
+ - Visible enemies with positions and health
367
+ - Recent events (kills, deaths)
368
+ """
369
+ agent = self._agents.get(self._current_agent_id)
370
+
371
+ lines = []
372
+ lines.append(f"=== Teeworlds Game State (Tick {self._tick}) ===")
373
+ lines.append("")
374
+
375
+ if not agent or not agent.is_alive:
376
+ lines.append("STATUS: DEAD - Waiting for respawn...")
377
+ lines.append("")
378
+ else:
379
+ lines.append(f"Position: ({agent.x:.0f}, {agent.y:.0f}) | Velocity: ({agent.vel_x:.1f}, {agent.vel_y:.1f})")
380
+ lines.append(f"Health: {agent.health}/10 | Armor: {agent.armor}/10")
381
+
382
+ wpn = WEAPONS[agent.weapon]
383
+ ammo_str = str(agent.ammo[agent.weapon]) if wpn["ammo"] > 0 else "infinite"
384
+ lines.append(f"Weapon: {wpn['name']} ({ammo_str} ammo)")
385
+ lines.append(f"Score: {agent.score} kills")
386
+ lines.append(f"Aim: ({agent.aim_x:.0f}, {agent.aim_y:.0f})")
387
+ lines.append("")
388
+
389
+ # Other players
390
+ enemies = []
391
+ for other_id, other in self._agents.items():
392
+ if other_id == self._current_agent_id:
393
+ continue
394
+
395
+ if other.is_alive:
396
+ dx = other.x - agent.x if agent else other.x
397
+ dy = other.y - agent.y if agent else other.y
398
+ dist = math.sqrt(dx*dx + dy*dy)
399
+ wpn_name = WEAPONS[other.weapon]["name"]
400
+ enemies.append(
401
+ f" - Player {other_id}: pos({other.x:.0f}, {other.y:.0f}), "
402
+ f"{other.health}HP, {wpn_name}, {dist:.0f} units away"
403
+ )
404
+ else:
405
+ enemies.append(f" - Player {other_id}: DEAD")
406
+
407
+ if enemies:
408
+ lines.append("OTHER PLAYERS:")
409
+ lines.extend(enemies)
410
+ else:
411
+ lines.append("OTHER PLAYERS: None")
412
+ lines.append("")
413
+
414
+ # Recent kills
415
+ recent = self._kill_events[-5:] if self._kill_events else []
416
+ if recent:
417
+ lines.append("RECENT EVENTS:")
418
+ for event in recent:
419
+ killer = event["killer_id"]
420
+ victim = event["victim_id"]
421
+ wpn_name = WEAPONS[event["weapon"]]["name"]
422
+ if killer == self._current_agent_id:
423
+ lines.append(f" - You killed Player {victim} with {wpn_name}")
424
+ elif victim == self._current_agent_id:
425
+ lines.append(f" - Player {killer} killed you with {wpn_name}")
426
+ else:
427
+ lines.append(f" - Player {killer} killed Player {victim} with {wpn_name}")
428
+ lines.append("")
429
+
430
+ lines.append("AVAILABLE ACTIONS: move, jump, aim, shoot, hook, get_status")
431
+
432
+ return "\n".join(lines)
433
+
434
+ # Store MCP reference and pass to base class
435
+ self._mcp = mcp
436
+ super().__init__(mcp)
437
+
438
+ def reset(
439
+ self,
440
+ seed: Optional[int] = None,
441
+ episode_id: Optional[str] = None,
442
+ **kwargs: Any,
443
+ ) -> Observation:
444
+ """
445
+ Reset the environment for a new episode.
446
+
447
+ Args:
448
+ seed: Optional random seed
449
+ episode_id: Optional episode ID
450
+ **kwargs: Additional reset options
451
+
452
+ Returns:
453
+ Observation indicating the environment is ready
454
+ """
455
+ if seed is not None:
456
+ random.seed(seed)
457
+
458
+ # Reset episode state
459
+ self._state = State(
460
+ episode_id=episode_id or str(uuid4()),
461
+ step_count=0,
462
+ )
463
+ self._tick = 0
464
+ self._kill_events = []
465
+
466
+ # Initialize agents
467
+ self._agents = {}
468
+ for i in range(self._num_agents):
469
+ self._agents[i] = GameAgent(i)
470
+
471
+ # Get initial status
472
+ status = self._get_status_text()
473
+
474
+ return Observation(
475
+ done=False,
476
+ reward=0.0,
477
+ metadata={
478
+ "status": "ready",
479
+ "message": status,
480
+ "episode_id": self._state.episode_id,
481
+ },
482
+ )
483
+
484
+ def _get_status_text(self) -> str:
485
+ """Generate current game status text."""
486
+ agent = self._agents.get(self._current_agent_id)
487
+
488
+ lines = []
489
+ lines.append(f"=== Teeworlds Game State (Tick {self._tick}) ===")
490
+
491
+ if agent and agent.is_alive:
492
+ lines.append(f"Position: ({agent.x:.0f}, {agent.y:.0f})")
493
+ lines.append(f"Health: {agent.health}/10 | Armor: {agent.armor}/10")
494
+ wpn = WEAPONS[agent.weapon]
495
+ lines.append(f"Weapon: {wpn['name']}")
496
+ lines.append(f"Score: {agent.score} kills")
497
+ else:
498
+ lines.append("STATUS: DEAD")
499
+
500
+ return "\n".join(lines)
501
+
502
+ def _simulate_tick(self):
503
+ """Simulate one game tick (physics, AI, etc.)."""
504
+ self._tick += 1
505
+
506
+ for agent in self._agents.values():
507
+ if not agent.is_alive:
508
+ continue
509
+
510
+ # Apply gravity
511
+ agent.vel_y += 0.5
512
+
513
+ # Apply velocity
514
+ agent.x += agent.vel_x
515
+ agent.y += agent.vel_y
516
+
517
+ # Ground collision (simple)
518
+ if agent.y > 500:
519
+ agent.y = 500
520
+ agent.vel_y = 0
521
+ agent.is_grounded = True
522
+
523
+ # Wall collision
524
+ agent.x = max(50, min(750, agent.x))
525
+
526
+ # Friction
527
+ agent.vel_x *= 0.95
528
+
529
+ # Simple AI for non-player agents
530
+ if agent.agent_id != self._current_agent_id:
531
+ self._simple_ai(agent)
532
+
533
+ def _simple_ai(self, agent: GameAgent):
534
+ """Simple AI behavior for non-player agents."""
535
+ # Random movement
536
+ if random.random() < 0.1:
537
+ agent.vel_x += random.uniform(-3, 3)
538
+
539
+ # Random jump
540
+ if agent.is_grounded and random.random() < 0.05:
541
+ agent.vel_y = -10
542
+ agent.is_grounded = False
543
+
544
+ # Aim at player
545
+ player = self._agents.get(self._current_agent_id)
546
+ if player and player.is_alive:
547
+ agent.aim_x = player.x
548
+ agent.aim_y = player.y
549
+
550
+ # Occasionally shoot
551
+ if random.random() < 0.02:
552
+ dx = player.x - agent.x
553
+ dy = player.y - agent.y
554
+ dist = math.sqrt(dx*dx + dy*dy)
555
+
556
+ if dist < 300:
557
+ # Attack player
558
+ wpn = WEAPONS[agent.weapon]
559
+ if wpn["ammo"] < 0 or agent.ammo[agent.weapon] > 0:
560
+ if wpn["ammo"] > 0:
561
+ agent.ammo[agent.weapon] -= 1
562
+
563
+ # Check hit (simplified)
564
+ if dist < 200 and random.random() < 0.3:
565
+ damage = wpn["damage"]
566
+ player.health -= damage
567
+
568
+ if player.health <= 0:
569
+ player.is_alive = False
570
+ agent.score += 1
571
+ self._kill_events.append({
572
+ "killer_id": agent.agent_id,
573
+ "victim_id": self._current_agent_id,
574
+ "weapon": agent.weapon,
575
+ "tick": self._tick,
576
+ })
577
+
578
+ def _step_impl(
579
+ self,
580
+ action: Action,
581
+ timeout_s: Optional[float] = None,
582
+ **kwargs: Any,
583
+ ) -> Observation:
584
+ """
585
+ Handle non-MCP actions.
586
+
587
+ Args:
588
+ action: The action to execute
589
+ timeout_s: Optional timeout
590
+ **kwargs: Additional arguments
591
+
592
+ Returns:
593
+ Observation with error for unknown action types
594
+ """
595
+ return Observation(
596
+ done=False,
597
+ reward=0.0,
598
+ metadata={
599
+ "error": f"Unknown action type: {type(action).__name__}. "
600
+ "Use ListToolsAction or CallToolAction for MCP interactions."
601
+ },
602
+ )
603
+
604
+ def step(
605
+ self,
606
+ action: Action,
607
+ timeout_s: Optional[float] = None,
608
+ **kwargs: Any,
609
+ ) -> Observation:
610
+ """
611
+ Execute a step in the environment.
612
+
613
+ Args:
614
+ action: The MCP action to execute
615
+ timeout_s: Optional timeout
616
+ **kwargs: Additional arguments
617
+
618
+ Returns:
619
+ Observation from the action execution
620
+ """
621
+ # Increment step count
622
+ self._state.step_count += 1
623
+
624
+ # Simulate game tick
625
+ self._simulate_tick()
626
+
627
+ # Calculate reward
628
+ reward = self._calculate_reward()
629
+
630
+ # Check done
631
+ done = self._state.step_count >= self._max_steps
632
+
633
+ # Check if all enemies dead (win condition)
634
+ enemies_alive = sum(1 for a in self._agents.values()
635
+ if a.agent_id != self._current_agent_id and a.is_alive)
636
+ if enemies_alive == 0:
637
+ done = True
638
+ reward += 10.0 # Win bonus
639
+
640
+ # Check if player dead
641
+ player = self._agents.get(self._current_agent_id)
642
+ if player and not player.is_alive:
643
+ done = True
644
+ reward -= 5.0 # Death penalty
645
+
646
+ # Let the base class handle MCP actions
647
+ obs = super().step(action, timeout_s=timeout_s, **kwargs)
648
+
649
+ # Update observation with reward and done
650
+ obs.reward = reward
651
+ obs.done = done
652
+ obs.metadata["step"] = self._state.step_count
653
+ obs.metadata["tick"] = self._tick
654
+
655
+ return obs
656
+
657
+ def _calculate_reward(self) -> float:
658
+ """Calculate reward for current step."""
659
+ reward = 0.0
660
+
661
+ player = self._agents.get(self._current_agent_id)
662
+ if not player:
663
+ return reward
664
+
665
+ # Survival bonus
666
+ if player.is_alive:
667
+ reward += 0.01
668
+
669
+ # Kill bonus (from recent events)
670
+ for event in self._kill_events:
671
+ if event["tick"] == self._tick:
672
+ if event["killer_id"] == self._current_agent_id:
673
+ reward += 1.0
674
+ elif event["victim_id"] == self._current_agent_id:
675
+ reward -= 0.5
676
+
677
+ return reward
678
+
679
+ @property
680
+ def state(self) -> State:
681
+ """Get the current environment state."""
682
+ return self._state