4 import redis
.asyncio
as redis
9 # --- CONFIGURATION ---
10 MAX_CONCURRENT_OLLAMA_REQUESTS
= 3
11 ALLOW_TEXT_EMOTES
= False
13 # --- NEW: LOAD THE WORLD LORE ON STARTUP ---
15 if os
.path
.exists("world_lore.txt"):
16 with open("asl_lore.txt", "r", encoding
="utf-8") as f
:
17 WORLD_LORE
= f
.read().strip()
19 print("[WARNING] world_lore.txt not found. Running without global lore.")
22 semaphore
= asyncio
.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS
)
23 # Dictionary to keep individual conversations separate
26 async def process_message(r
, session
, message_data
):
28 data
= json
.loads(message_data
)
29 player_name
= data
.get('player', data
.get('target_player', 'Unknown'))
30 npc_tag
= data
.get('npc_tag', 'UnknownNPC')
31 message
= data
.get('message', '')
33 # --- NEW: THE ASTERISK SCRUBBER ---
34 if not ALLOW_TEXT_EMOTES
:
35 # This deletes anything wrapped in asterisks (e.g. "*smiles* Hello" becomes " Hello")
36 message
= re
.sub(r
'\*.*?\*', '', message
).strip()
38 # Extract the dynamic variables sent from the Aurora Toolset/Engine
39 player_race
= data
.get('player_race', 'Unknown')
40 player_alignment
= data
.get('player_alignment', 'Unknown')
41 nearby_players
= data
.get('nearby_players', '')
43 # Extract the decoupled NPC attributes
44 npc_persona
= data
.get('persona', 'You are a generic citizen.')
45 npc_profession
= data
.get('profession', 'Commoner')
46 npc_mood
= data
.get('mood', 'Neutral')
47 npc_secret
= data
.get('secret', '')
49 # Extract Character Traits & Native Engine Data
50 npc_alignment
= data
.get('npc_alignment', 'True Neutral')
51 npc_gender
= data
.get('npc_gender', 'Unknown')
52 npc_race
= data
.get('npc_race', 'Creature')
53 npc_routine
= data
.get('npc_routine', '')
55 # Build the context strings
58 group_context
= f
"Be aware that these other players are listening nearby: {nearby_players}."
62 secret_context
= f
"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}"
66 routine_context
= f
"YOUR REQUIRED ROUTINE: {npc_routine}"
68 # =====================================================================
70 # =====================================================================
71 dynamic_system_prompt
= f
"""
77 CURRENT STATUS & TRAITS:
78 - Race & Gender: {npc_gender} {npc_race}
79 - Profession: {npc_profession}
80 - Alignment: {npc_alignment}
81 - Conversational Charisma: Low/Gruff unless otherwise specified.
82 - Current Mood: {npc_mood}
87 CURRENT TARGET: You are speaking to {player_name}, who is a {player_alignment} {player_race}.
89 React appropriately based on your personality, alignment, and mood.
91 CRITICAL ENGINE RULES:
92 Respond ONLY in valid JSON. You MUST use exactly these FIVE keys: "thought", "speech", "emotion", "action", and "action_target".
95 Your "action" key MUST be exactly one of the following words:
96 [WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, ATTACK, REST, STEALTH, SEARCH, UNSTEALTH]
98 - Use REST if you are severely injured, out of spells, or exhausted. This will heal you.
99 - Use STEALTH if you need to hide from enemies, sneak past someone, or if you are a rogue preparing an ambush.
100 - Use SEARCH if you suspect traps, are looking for clues, or are trying to find hidden enemies.
101 - Use UNSTEALTH to return to normal walking/visibility.
104 Your "emotion" key MUST be exactly one of the following words:
105 [NEUTRAL, LAUGHING, ANGRY, PLEADING, BOW, TAUNT, CHEER]. Do not invent new emotions. Do not perform writen emotions in text with **.
107 YOUR RESPONSE MUST BE A SINGLE, VALID JSON OBJECT. YOU MUST USE THIS EXACT TEMPLATE:
109 "thought": "Your internal reasoning here.",
110 "speech": "You MUST say something out loud. If you don't want to talk, output something your character would do.",
111 "emotion": "MACRO WORD",
112 "action": "MACRO WORD",
113 "action_target": "Target name"
117 session_id
= f
"{player_name}_{npc_tag}"
119 if session_id
not in chat_memory
:
120 chat_memory
[session_id
] = [{"role": "system", "content": dynamic_system_prompt}
]
122 chat_memory
[session_id
].append({"role": "user", "content": f"{player_name} says
: {message}
"})
125 # Remember the System Prompt [0] + the last 4 messages [-4:]
126 if len(chat_memory[session_id]) > 10:
127 chat_memory[session_id] = [chat_memory[session_id][0]] + chat_memory[session_id][-5:]
129 async with semaphore:
130 print(f"[THINKING
] Processing reply
for {player_name}
...")
131 async with session.post('http://localhost:11434/api/chat', json={
133 #"model
": "qwen2
.5
:3b
",
134 "messages
": chat_memory[session_id],
141 }, timeout=45) as response:
143 response.raise_for_status()
144 result = await response.json()
145 raw_reply_text = result['message']['content']
147 # =====================================================================
148 # THE PYTHON BOUNCER (Sanitization)
149 # =====================================================================
151 agent_brain = json.loads(raw_reply_text)
152 agent_brain = {k.lower(): v for k, v in agent_brain.items()}
154 # Ensure all 5 keys exist
155 if "thought
" not in agent_brain: agent_brain["thought
"] = ""
156 if "speech
" not in agent_brain: agent_brain["speech
"] = ""
157 if "emotion
" not in agent_brain: agent_brain["emotion
"] = "NEUTRAL
"
158 if "action
" not in agent_brain: agent_brain["action
"] = "GUARD
"
159 if "action_target
" not in agent_brain: agent_brain["action_target
"] = ""
161 # --- THE NEW ANTI-SILENCE & TARGET CLEANUP FIXES ---
162 if not agent_brain["speech
"].strip():
163 agent_brain["speech
"] = "*grunts quietly
*"
165 agent_brain["action_target
"] = agent_brain["action_target
"].replace("?
", "").replace(".", "").strip()
166 # ---------------------------------------------------
168 clean_reply_text = json.dumps(agent_brain)
170 except json.JSONDecodeError:
171 print(f"[WARNING
] AI Hallucinated
! Overriding
with safe defaults
.")
172 clean_reply_text = json.dumps({
173 "thought
": "I lost my train of thought
.",
174 "speech
": "*grunts quietly
*",
175 "emotion
": "NEUTRAL
",
179 # =====================================================================
181 print(f"[REPLY
] from {npc_tag} to {player_name}
: {clean_reply_text}
")
182 chat_memory[session_id].append({"role": "assistant", "content": clean_reply_text})
186 "target_player
": player_name,
187 "reply
": clean_reply_text
189 await r.rpush('llm_to_nwn', json.dumps(reply_payload))
191 except Exception as e:
192 print(f"[ERROR
] Failed to process message
: {e}
")
195 print("Initializing Async Redis Bridge
...")
196 r = redis.Redis(host='127.0.0.1', port=6380, decode_responses=True)
200 print("SUCCESS
: Connected to the Docker Redis database
!")
201 except Exception as e:
202 print(f"CRITICAL ERROR
: Could
not connect to Redis
. {e}
")
205 print(f"Ready
! Listening
for game messages
. Max GPU concurrency
: {MAX_CONCURRENT_OLLAMA_REQUESTS}
")
207 async with aiohttp.ClientSession() as session:
210 result = await r.blpop('nwn_to_llm')
212 queue_name, message_data = result
213 asyncio.create_task(process_message(r, session, message_data))
214 except Exception as e:
215 print(f"[LOOP ERROR
] {e}
")
216 await asyncio.sleep(1)
218 if __name__ == "__main__
":