callai-rpg 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- callai_rpg-0.1.1/.gitignore +14 -0
- callai_rpg-0.1.1/PKG-INFO +92 -0
- callai_rpg-0.1.1/README.md +78 -0
- callai_rpg-0.1.1/docs/CONFIG_GUIDE.md +57 -0
- callai_rpg-0.1.1/pyproject.toml +28 -0
- callai_rpg-0.1.1/requirements.txt +3 -0
- callai_rpg-0.1.1/rpg/__init__.py +5 -0
- callai_rpg-0.1.1/rpg/ai/event_handler.py +170 -0
- callai_rpg-0.1.1/rpg/ai/llm_runner.py +328 -0
- callai_rpg-0.1.1/rpg/ai/memory.py +74 -0
- callai_rpg-0.1.1/rpg/ai/tools.py +621 -0
- callai_rpg-0.1.1/rpg/app.py +1047 -0
- callai_rpg-0.1.1/rpg/cli/main.py +106 -0
- callai_rpg-0.1.1/rpg/config.py +191 -0
- callai_rpg-0.1.1/rpg/core/items.py +103 -0
- callai_rpg-0.1.1/rpg/core/player_actions.py +340 -0
- callai_rpg-0.1.1/rpg/core/storage.py +142 -0
- callai_rpg-0.1.1/rpg/core/types.py +14 -0
- callai_rpg-0.1.1/rpg/core/utils.py +38 -0
- callai_rpg-0.1.1/rpg/entities/base.py +96 -0
- callai_rpg-0.1.1/rpg/entities/brains.py +423 -0
- callai_rpg-0.1.1/rpg/entities/npc.py +237 -0
- callai_rpg-0.1.1/rpg/entities/player.py +11 -0
- callai_rpg-0.1.1/rpg/network.py +258 -0
- callai_rpg-0.1.1/rpg/test_rpy.py +241 -0
- callai_rpg-0.1.1/rpg/ui/renderer.py +396 -0
- callai_rpg-0.1.1/rpg/ui/speech_bubble.py +81 -0
- callai_rpg-0.1.1/rpg/world/actions.py +923 -0
- callai_rpg-0.1.1/rpg/world/collision.py +42 -0
- callai_rpg-0.1.1/rpg/world/map.py +622 -0
- callai_rpg-0.1.1/rpg/world/pathfinding.py +124 -0
- callai_rpg-0.1.1/rpg/world/population.py +186 -0
- callai_rpg-0.1.1/rpg/world/resolver.py +201 -0
- callai_rpg-0.1.1/rpg/world/tiles.py +32 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: callai-rpg
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: LLM-powered top-down RPG game
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: asyncio
|
|
7
|
+
Requires-Dist: pygame-ce>=2.5.0
|
|
8
|
+
Requires-Dist: python-dotenv>=0.1.0
|
|
9
|
+
Requires-Dist: websockets>=11.0.3
|
|
10
|
+
Provides-Extra: hosting
|
|
11
|
+
Requires-Dist: callai-agentic-core-rag[gemini]>=0.1.0; extra == 'hosting'
|
|
12
|
+
Requires-Dist: callai-agentic-core[openai]>=0.6.6; extra == 'hosting'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# My RPG (Multiplayer-Ready)
|
|
16
|
+
|
|
17
|
+
A fully networked, Thick-Host/Thin-Client RPG engine driven by autonomous LLM agents.
|
|
18
|
+
|
|
19
|
+
## Run
|
|
20
|
+
|
|
21
|
+
First, install dependencies:
|
|
22
|
+
```bat
|
|
23
|
+
pip install -r requirements.txt
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Multiplayer Mode
|
|
27
|
+
|
|
28
|
+
**1. Start the Dedicated Host Server (Headless):**
|
|
29
|
+
```bat
|
|
30
|
+
python main.py --host
|
|
31
|
+
```
|
|
32
|
+
*(The server will start listening on `0.0.0.0:5555` by default).*
|
|
33
|
+
|
|
34
|
+
**2. Start a Thin Client:**
|
|
35
|
+
```bat
|
|
36
|
+
python main.py --join 127.0.0.1
|
|
37
|
+
```
|
|
38
|
+
*(You can launch as many clients as you want. The server will dynamically assign them IDs like `remote_1`, `remote_2` and replicate the state).*
|
|
39
|
+
|
|
40
|
+
### Singleplayer / Dev Mode (Local Bypass)
|
|
41
|
+
To run the game locally without network serialization overhead:
|
|
42
|
+
```bat
|
|
43
|
+
python main.py
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Example
|
|
47
|
+
|
|
48
|
+
#### Player 1 (The Host):
|
|
49
|
+
1. Runs `python main.py --host`
|
|
50
|
+
2. Opens a new terminal and runs an HTTP tunnel wrapper: `ngrok http 5555`
|
|
51
|
+
3. Copies the forwarding URL provided by ngrok/your tunelling service (e.g., https://1234-abcd.ngrok-free.app).
|
|
52
|
+
|
|
53
|
+
#### Player 2 (The Client):
|
|
54
|
+
Runs `python main.py --join 1234-abcd.ngrok-free.app`
|
|
55
|
+
(The engine automatically maps this to wss://1234-abcd.ngrok-free.app and successfully routes the TCP streams through the HTTPS tunnel).
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## LLM Setup
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
### OpenAI (fallback)
|
|
62
|
+
If Ollama fails to initialize, it falls back to `OpenAILLM()`.
|
|
63
|
+
|
|
64
|
+
Set the following environment variables:
|
|
65
|
+
|
|
66
|
+
- `OPENAI_API_KEY`
|
|
67
|
+
- `OPENAI_BASE_URL` (optional; defaults to `https://api.openai.com/v1`)
|
|
68
|
+
- `OPENAI_MODEL` (optional; required if OPENAI_BASE_URL is specified)
|
|
69
|
+
|
|
70
|
+
You can place these in a local `.env` file, then specify their path: `python main.py --env-file .env'
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
## Controls
|
|
74
|
+
|
|
75
|
+
- **Arrows**: move player
|
|
76
|
+
- **SPACE**: interact with nearby NPC, objects, doors, containers, and items.
|
|
77
|
+
- **ENTER**: Open a dialogue -> **Type** + **ENTER**: speak aloud, will be broadcasted to all nearby NPCs.
|
|
78
|
+
> Note: For best performance, prefix your message to a specific NPC with '(To `npc_name`)', or Whisper to them via the interaction menu (must be standing close to them).
|
|
79
|
+
|
|
80
|
+
- **TAB**: open inventory
|
|
81
|
+
- *D*: drop the currently selected item in your inventory, will prompt a dialogue to specify amount to drop.
|
|
82
|
+
- *L*: Global NPCs and event log.
|
|
83
|
+
- **ESC**: close dialogue
|
|
84
|
+
|
|
85
|
+
## Interaction options
|
|
86
|
+
|
|
87
|
+
The following options are available in the interaction menu, subject to each object/entity type:
|
|
88
|
+
|
|
89
|
+
- **NPCs**: Get their attention, Whisper to them, Give them gold
|
|
90
|
+
- **Doors**: Openning them (if unlocked, or when the correct key is possessed in inventory when locked), Knock on them
|
|
91
|
+
- **Containers**: Take away or Store items.
|
|
92
|
+
- **Workstations/Harvestable objects** (e.g. trees): harvest/craft new items from them at the expense of some specific resources (if applicable).
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# My RPG (Multiplayer-Ready)
|
|
2
|
+
|
|
3
|
+
A fully networked, Thick-Host/Thin-Client RPG engine driven by autonomous LLM agents.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
First, install dependencies:
|
|
8
|
+
```bat
|
|
9
|
+
pip install -r requirements.txt
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
### Multiplayer Mode
|
|
13
|
+
|
|
14
|
+
**1. Start the Dedicated Host Server (Headless):**
|
|
15
|
+
```bat
|
|
16
|
+
python main.py --host
|
|
17
|
+
```
|
|
18
|
+
*(The server will start listening on `0.0.0.0:5555` by default).*
|
|
19
|
+
|
|
20
|
+
**2. Start a Thin Client:**
|
|
21
|
+
```bat
|
|
22
|
+
python main.py --join 127.0.0.1
|
|
23
|
+
```
|
|
24
|
+
*(You can launch as many clients as you want. The server will dynamically assign them IDs like `remote_1`, `remote_2` and replicate the state).*
|
|
25
|
+
|
|
26
|
+
### Singleplayer / Dev Mode (Local Bypass)
|
|
27
|
+
To run the game locally without network serialization overhead:
|
|
28
|
+
```bat
|
|
29
|
+
python main.py
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Example
|
|
33
|
+
|
|
34
|
+
#### Player 1 (The Host):
|
|
35
|
+
1. Runs `python main.py --host`
|
|
36
|
+
2. Opens a new terminal and runs an HTTP tunnel wrapper: `ngrok http 5555`
|
|
37
|
+
3. Copies the forwarding URL provided by ngrok/your tunelling service (e.g., https://1234-abcd.ngrok-free.app).
|
|
38
|
+
|
|
39
|
+
#### Player 2 (The Client):
|
|
40
|
+
Runs `python main.py --join 1234-abcd.ngrok-free.app`
|
|
41
|
+
(The engine automatically maps this to wss://1234-abcd.ngrok-free.app and successfully routes the TCP streams through the HTTPS tunnel).
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## LLM Setup
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### OpenAI (fallback)
|
|
48
|
+
If Ollama fails to initialize, it falls back to `OpenAILLM()`.
|
|
49
|
+
|
|
50
|
+
Set the following environment variables:
|
|
51
|
+
|
|
52
|
+
- `OPENAI_API_KEY`
|
|
53
|
+
- `OPENAI_BASE_URL` (optional; defaults to `https://api.openai.com/v1`)
|
|
54
|
+
- `OPENAI_MODEL` (optional; required if OPENAI_BASE_URL is specified)
|
|
55
|
+
|
|
56
|
+
You can place these in a local `.env` file, then specify their path: `python main.py --env-file .env'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## Controls
|
|
60
|
+
|
|
61
|
+
- **Arrows**: move player
|
|
62
|
+
- **SPACE**: interact with nearby NPC, objects, doors, containers, and items.
|
|
63
|
+
- **ENTER**: Open a dialogue -> **Type** + **ENTER**: speak aloud, will be broadcasted to all nearby NPCs.
|
|
64
|
+
> Note: For best performance, prefix your message to a specific NPC with '(To `npc_name`)', or Whisper to them via the interaction menu (must be standing close to them).
|
|
65
|
+
|
|
66
|
+
- **TAB**: open inventory
|
|
67
|
+
- *D*: drop the currently selected item in your inventory, will prompt a dialogue to specify amount to drop.
|
|
68
|
+
- *L*: Global NPCs and event log.
|
|
69
|
+
- **ESC**: close dialogue
|
|
70
|
+
|
|
71
|
+
## Interaction options
|
|
72
|
+
|
|
73
|
+
The following options are available in the interaction menu, subject to each object/entity type:
|
|
74
|
+
|
|
75
|
+
- **NPCs**: Get their attention, Whisper to them, Give them gold
|
|
76
|
+
- **Doors**: Openning them (if unlocked, or when the correct key is possessed in inventory when locked), Knock on them
|
|
77
|
+
- **Containers**: Take away or Store items.
|
|
78
|
+
- **Workstations/Harvestable objects** (e.g. trees): harvest/craft new items from them at the expense of some specific resources (if applicable).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
### **Game Engine Configuration (`config.py`)**
|
|
2
|
+
|
|
3
|
+
The configuration variables dictate the fundamental physics, AI boundaries, and survival mechanics of the game. They are grouped into specific sub-systems below.
|
|
4
|
+
|
|
5
|
+
#### **1. Display & Environment**
|
|
6
|
+
|
|
7
|
+
Controls the physical rendering window and base grid constraints.
|
|
8
|
+
|
|
9
|
+
| Variable | Description |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| **`WIDTH`** | The rendering window width in pixels. |
|
|
12
|
+
| **`HEIGHT`** | The rendering window height in pixels. |
|
|
13
|
+
| **`FPS`** | The target frames-per-second for the Pygame clock. |
|
|
14
|
+
| **`TILE_SIZE`** | The pixel size of a single square grid tile. |
|
|
15
|
+
|
|
16
|
+
#### **2. AI Priorities & Processing Limits**
|
|
17
|
+
|
|
18
|
+
Determines how the `agentic_core` prioritizes events sent to the LLM queues to prevent minor events from blocking critical reactions. Lower numbers indicate higher priority.
|
|
19
|
+
|
|
20
|
+
| Variable | Description |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| **`PRIO_COMBAT`** | Highest priority. Triggered when attacked or entering combat. |
|
|
23
|
+
| **`PRIO_DIRECT_INTERACT`** | Triggered when a player or NPC directly speaks to, gives an item to, or knocks for the NPC. |
|
|
24
|
+
| **`PRIO_SPEECH`** | General overheard dialogue from other characters. |
|
|
25
|
+
| **`PRIO_OBSERVATION`** | Visual updates (e.g., someone enters/leaves the field of view). |
|
|
26
|
+
| **`PRIO_IDLE`** | Lowest priority. Background thoughts or scheduled routine pings. |
|
|
27
|
+
| **`LLM_MAX_CONCURRENT_REQUEST`** | The maximum number of simultaneous API calls allowed. |
|
|
28
|
+
| **`LLM_INTERRUPT_PRIORITY_THRESHOLD`** | Events with this priority or lower will instantly cancel an ongoing LLM generation task. |
|
|
29
|
+
|
|
30
|
+
#### **3. Vision, Senses & Interaction Limits**
|
|
31
|
+
|
|
32
|
+
Defines the spatial awareness of NPCs. Distances are calculated in tiles.
|
|
33
|
+
|
|
34
|
+
| Variable | Description |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| **`VISION_RANGE_TILE`** | The maximum radius an NPC can detect entities or items. |
|
|
37
|
+
| **`ROOM_VISIBLE_RANGE_TILE`** | How far an NPC can "see" a room's center to know it exists. |
|
|
38
|
+
| **`SPEECH_OBSERVATION_RADIUS_TILE`** | The maximum distance text chat can be "heard" by others. |
|
|
39
|
+
| **`PHYSICAL_INTERACTION_RADIUS_TILE`** | The distance an NPC will notice environmental changes (doors opening, items dropping). |
|
|
40
|
+
| **`GIVE_MAX_DISTANCE_TILE`** | Maximum distance allowed to execute the `give_item` or `give_gold` tools. |
|
|
41
|
+
| **`WHISPER_MAX_DISTANCE_TILE`** | Maximum distance to execute a private `whisper` tool. |
|
|
42
|
+
| **`COMBAT_STRIKE_DISTANCE_TILE`** | The physical proximity required to land a melee attack. |
|
|
43
|
+
| **`KNOCK_SOUND_BASE`** | Base radius of a knock sound, scaling inversely with room occupants. |
|
|
44
|
+
|
|
45
|
+
#### **4. NPC Behavior & The Maslow Engine**
|
|
46
|
+
|
|
47
|
+
Controls the physiological needs, patience, and scheduling of the autonomous agents.
|
|
48
|
+
|
|
49
|
+
| Variable | Description |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| **`TIME_SCALE`** | 1 real-world second equals 60 in-game seconds (1 real minute = 1 in-game hour). |
|
|
52
|
+
| **`NEEDS_DECAY_RATE_PER_MIN`** | Hunger increases and energy decreases by 0.1 every in-game minute. |
|
|
53
|
+
| **`CHAOS_TICK_INTERVAL_SEC`** | Frequency (in real seconds) of pinging off-screen NPCs to force background simulation. |
|
|
54
|
+
| **`NPC_WAIT_AFTER_ARRIVAL_SEC`** | How long an NPC pauses after reaching a destination before determining their next move. |
|
|
55
|
+
| **`NPC_PATIENCE_WAIT_SEC`** | How long an NPC will wait for a response after handing an item or speaking to someone. |
|
|
56
|
+
| **`HIBERNATE_DISTANCE_TILES`** | Distance from the player at which an NPC stops active rendering/processing and enters a sleep state. |
|
|
57
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "callai-rpg"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "LLM-powered top-down RPG game"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pygame-ce>=2.5.0",
|
|
9
|
+
"asyncio",
|
|
10
|
+
"websockets>=11.0.3",
|
|
11
|
+
"python-dotenv>=0.1.0"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
carpg = "rpg.cli.main:main"
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
hosting = [
|
|
19
|
+
"callai-agentic_core[openai]>=0.6.6",
|
|
20
|
+
"callai-agentic_core-rag[gemini]>=0.1.0"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["rpg"]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import queue
|
|
4
|
+
import re
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from ..core.types import GameAction
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from agentic_core.handlers.standard import SmartRetryHandler
|
|
11
|
+
from agentic_core.decisions import ErrorDecision, DecisionEvent
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# Global state to stagger API retries and prevent Thundering Herd DDoS
|
|
16
|
+
GLOBAL_API_BACKOFF_UNTIL = 0.0
|
|
17
|
+
|
|
18
|
+
def clean_speech(text: str) -> str:
|
|
19
|
+
"""Strip emoji, non-printable characters, and RP action annotations."""
|
|
20
|
+
# 0. Heuristic XML extraction: If the model wrapped the actual speech in a tag
|
|
21
|
+
xml_match = re.search(r'<(speech|dialogue|message|say|text)\b[^>]*>(.*?)</\1>', text, flags=re.IGNORECASE | re.DOTALL)
|
|
22
|
+
if xml_match:
|
|
23
|
+
cleaned_text = xml_match.group(2)
|
|
24
|
+
if cleaned_text: text = cleaned_text
|
|
25
|
+
|
|
26
|
+
# Strip any remaining loose XML/HTML-like tags (e.g. leaked <thought> blocks)
|
|
27
|
+
text = re.sub(r'<[a-zA-Z\/][^>]*>', '', text)
|
|
28
|
+
|
|
29
|
+
# 1. Strip emojis and non-printables
|
|
30
|
+
text = re.sub(r"[^\x20-\x7E]", "", text)
|
|
31
|
+
|
|
32
|
+
# 2. Strip *asterisk* RP actions (e.g., *smiles*, *walks away*)
|
|
33
|
+
text = re.sub(r'\*[^*]+\*', '', text)
|
|
34
|
+
|
|
35
|
+
# 3. Strip [bracket] RP actions
|
|
36
|
+
text = re.sub(r'\[[^\]]+\]', '', text)
|
|
37
|
+
|
|
38
|
+
# 4. Strip (parentheses) RP actions, BUT preserve conversation targeting like "(To Alice)"
|
|
39
|
+
# Using negative lookahead to ignore if it starts with "To " or "to "
|
|
40
|
+
text = re.sub(r'\((?![Tt]o\s)[^)]+\)', '', text)
|
|
41
|
+
|
|
42
|
+
# 5. Clean up weird double spaces left behind by the regex
|
|
43
|
+
text = re.sub(r'\s{2,}', ' ', text)
|
|
44
|
+
|
|
45
|
+
return text.strip()
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
class PrettyDict(str):
|
|
49
|
+
"""A dictionary that automatically strings itself with beautiful formatting."""
|
|
50
|
+
def __str__(self):
|
|
51
|
+
try:
|
|
52
|
+
placeholder = json.loads(self)
|
|
53
|
+
# indent=4 creates the nesting, sort_keys keeps it predictable
|
|
54
|
+
return json.dumps(placeholder, indent=4, ensure_ascii=False, default=str)
|
|
55
|
+
except Exception:
|
|
56
|
+
return super()
|
|
57
|
+
|
|
58
|
+
def __repr__(self):
|
|
59
|
+
return self.__str__()
|
|
60
|
+
|
|
61
|
+
class PygameEventHandler(SmartRetryHandler):
|
|
62
|
+
"""Bridges agentic_core events back into the game thread via game_action_queue."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, npc_id: int, game_action_queue: queue.Queue, npc_name: str | None = None):
|
|
65
|
+
# Configure exponential backoff: up to 4 retries, starting at a 2.0s delay
|
|
66
|
+
super().__init__(max_retries=4, base_delay=2.0)
|
|
67
|
+
self.npc_name = npc_name
|
|
68
|
+
self.npc_id = npc_id
|
|
69
|
+
self.game_action_queue = game_action_queue
|
|
70
|
+
self.has_used_tool = False
|
|
71
|
+
self.has_spoken_this_turn = False
|
|
72
|
+
|
|
73
|
+
self.active_tool_calls = {}
|
|
74
|
+
|
|
75
|
+
async def on_turn_start(self) -> None:
|
|
76
|
+
self.has_used_tool = False
|
|
77
|
+
self.has_spoken_this_turn = False
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
async def on_tool_start(self, tool_name: str, tool_id: str, tool_arg=None):
|
|
81
|
+
arg_display = str(PrettyDict(tool_arg)) if tool_arg else "{}"
|
|
82
|
+
self.active_tool_calls[tool_id] = arg_display
|
|
83
|
+
return await super().on_tool_start(tool_name, tool_id, tool_arg)
|
|
84
|
+
|
|
85
|
+
async def on_tool_complete(self, tool_name: str, tool_id: str, success: bool, result: str) -> None:
|
|
86
|
+
logger.info(f"\n=============\n\n{self.npc_name}: '{tool_name}'\nArguments: {self.active_tool_calls[tool_id]}\nResult: {result}\n\n=============")
|
|
87
|
+
self.has_used_tool = True
|
|
88
|
+
|
|
89
|
+
# Clear speech debounce so post-tool dialogue appears instantly
|
|
90
|
+
self.game_action_queue.put(GameAction(npc_id=self.npc_id, action_type="clear_speech_timer"))
|
|
91
|
+
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
async def on_turn_complete(self, response) -> None:
|
|
95
|
+
text = ""
|
|
96
|
+
try:
|
|
97
|
+
text = response.text.strip()
|
|
98
|
+
except Exception:
|
|
99
|
+
text = "(LLM produced invalid response, please try again.)"
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
text = clean_speech(text)
|
|
103
|
+
|
|
104
|
+
# --- Filter out silence indicators ---
|
|
105
|
+
if text.lower() in ("...", "silence", "*silence*", "*nods*", "*waits*", '""', "''"):
|
|
106
|
+
text = ""
|
|
107
|
+
|
|
108
|
+
if text:
|
|
109
|
+
self.game_action_queue.put(
|
|
110
|
+
GameAction(npc_id=self.npc_id, action_type="speak", payload=text)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Send debug info back to main thread
|
|
114
|
+
tool_calls_str = ", ".join([tc['function']['name'] for tc in response.tool_calls]) if response.tool_calls else "None"
|
|
115
|
+
reasoning_str = response.reasoning if response.reasoning else "(No reasoning provided)"
|
|
116
|
+
self.game_action_queue.put(
|
|
117
|
+
GameAction(npc_id=self.npc_id, action_type="update_debug_state", payload={
|
|
118
|
+
"reasoning": reasoning_str,
|
|
119
|
+
"tool_calls": tool_calls_str
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def on_error(self, error_context):
|
|
124
|
+
err = error_context.error
|
|
125
|
+
|
|
126
|
+
# Provider Infrastructure Bug Catcher:
|
|
127
|
+
# If the provider sends malformed SSE chunks (often a hidden rate limit or concurrency choke),
|
|
128
|
+
# force the engine to treat it as a transient error and back off.
|
|
129
|
+
if err and "JSONDecodeError" in type(err).__name__:
|
|
130
|
+
if error_context.retry_count < self.max_retries:
|
|
131
|
+
decision = DecisionEvent(action=ErrorDecision.RETRY(delay=self.base_delay, exponential_base=2.0))
|
|
132
|
+
else:
|
|
133
|
+
decision = DecisionEvent(action=ErrorDecision.ABANDON())
|
|
134
|
+
else:
|
|
135
|
+
# 1. Let the SmartRetryHandler decide if this is a transient error (RateLimit/Timeout)
|
|
136
|
+
decision = await super().on_error(error_context)
|
|
137
|
+
|
|
138
|
+
# 2. If it decided to retry with backoff, apply global staggering
|
|
139
|
+
if isinstance(decision.action, ErrorDecision.RETRY):
|
|
140
|
+
global GLOBAL_API_BACKOFF_UNTIL
|
|
141
|
+
now = time.time()
|
|
142
|
+
|
|
143
|
+
# If the global backoff is already in the future, add our delay on top to stagger
|
|
144
|
+
if now < GLOBAL_API_BACKOFF_UNTIL:
|
|
145
|
+
decision.action.delay += (GLOBAL_API_BACKOFF_UNTIL - now)
|
|
146
|
+
GLOBAL_API_BACKOFF_UNTIL += decision.action.delay
|
|
147
|
+
else:
|
|
148
|
+
GLOBAL_API_BACKOFF_UNTIL = now + decision.action.delay
|
|
149
|
+
|
|
150
|
+
logger.info(f"NPC {self.npc_name} API call failed. Staggering retry by {decision.action.delay:.1f}s...")
|
|
151
|
+
return decision
|
|
152
|
+
|
|
153
|
+
# 3. If it's a fatal error (Auth error, max retries exceeded, etc.), log it to the HUD
|
|
154
|
+
try:
|
|
155
|
+
err = str(getattr(error_context, "error", "Unknown error"))
|
|
156
|
+
except Exception:
|
|
157
|
+
err = "Unknown error"
|
|
158
|
+
|
|
159
|
+
# Route to HUD instead of shouting out loud
|
|
160
|
+
self.game_action_queue.put(
|
|
161
|
+
GameAction(npc_id=self.npc_id, action_type="log_error", payload=err)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
import traceback
|
|
165
|
+
if hasattr(error_context, "error") and error_context.error is not None:
|
|
166
|
+
error_list = traceback.format_exception(type(error_context.error), error_context.error, error_context.error.__traceback__)
|
|
167
|
+
error_string = "".join(error_list)
|
|
168
|
+
logger.warning(f"Fatal LLM error for NPC {self.npc_id}: {error_string}")
|
|
169
|
+
|
|
170
|
+
return decision
|