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.
@@ -0,0 +1,14 @@
1
+ __pycache__
2
+ .env*
3
+ .pytest_cache
4
+ *.log
5
+ TODO.md
6
+ upload
7
+ library_lore.db
8
+ savegame.json
9
+ .blackboxrules
10
+ .vscode
11
+ venv
12
+ build
13
+ dist
14
+ *.db
@@ -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,3 @@
1
+ pygame-ce>=2.5.0
2
+ asyncio
3
+ websockets>=11.0.3
@@ -0,0 +1,5 @@
1
+ """RPG package extracted from the original monolithic main.py.
2
+
3
+ Keep root main.py as a compatibility facade for existing imports.
4
+ """
5
+
@@ -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