4 import redis
.asyncio
as redis
8 # --- CONFIGURATION ---
9 MAX_CONCURRENT_OLLAMA_REQUESTS
= 3
10 ALLOW_TEXT_EMOTES
= False
12 # --- LOAD THE WORLD LORE ON STARTUP ---
14 if os
.path
.exists("asl_lore.txt"):
15 with open("asl_lore.txt", "r", encoding
="utf-8") as f
:
16 WORLD_LORE
= f
.read().strip()
18 print("[WARNING] asl_lore.txt/asl_lore.txt not found. Running without global lore.")
20 semaphore
= asyncio
.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS
)
21 # Dictionary to keep individual conversations separate
24 async def process_message(r
, session
, message_data
):
26 data
= json
.loads(message_data
)
27 player_name
= data
.get('player', data
.get('target_player', 'Unknown'))
28 npc_tag
= data
.get('npc_tag', 'UnknownNPC')
29 message
= data
.get('message', '')
31 # --- THE ASTERISK SCRUBBER ---
32 if not ALLOW_TEXT_EMOTES
:
33 message
= re
.sub(r
'\*.*?\*', '', message
).strip()
35 # Extract the dynamic variables sent from the Aurora Toolset/Engine
36 player_race
= data
.get('player_race', 'Unknown')
37 player_alignment
= data
.get('player_alignment', 'Unknown')
38 nearby_players
= data
.get('nearby_players', '')
40 # --- Extract States and Health ---
41 player_state
= data
.get('player_state', 'Relaxed and unarmed.')
42 world_state
= data
.get('world_state', 'Nothing of note is happening.')
43 # --- Extract Geographic Awareness ---
44 location_context
= data
.get('location_context', 'You are in a generic area.')
45 # --- Extract NPC health ---
46 npc_health
= data
.get('npc_health', 'Healthy and uninjured.')
47 # --- Extract Relationship ---
48 relationship
= data
.get('relationship', 'Neutral or Friendly.')
50 # Extract the decoupled NPC attributes
51 npc_persona
= data
.get('persona', 'You are a generic citizen.')
52 npc_profession
= data
.get('profession', 'Commoner')
53 npc_mood
= data
.get('mood', 'Neutral')
54 npc_secret
= data
.get('secret', '')
56 # Extract Character Traits & Native Engine Data
57 npc_alignment
= data
.get('npc_alignment', 'True Neutral')
58 npc_gender
= data
.get('npc_gender', 'Unknown')
59 npc_race
= data
.get('npc_race', 'Creature')
60 npc_routine
= data
.get('npc_routine', '')
62 # Build the context strings
65 group_context
= f
"Be aware that these other players are listening nearby: {nearby_players}."
69 secret_context
= f
"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}"
73 routine_context
= f
"YOUR REQUIRED ROUTINE: {npc_routine}"
75 # =====================================================================
77 # =====================================================================
78 dynamic_system_prompt
= f
"""
84 CURRENT STATUS & TRAITS:
85 - Race & Gender: {npc_gender} {npc_race}
86 - Profession: {npc_profession}
87 - Alignment: {npc_alignment}
88 - Conversational Charisma: Based on mood, profession and your character charisma.
89 - Current Mood: {npc_mood}
90 - Current Physical State: {npc_health}
94 CURRENT LOCATION: {location_context}
96 CURRENT WORLD RUMORS/EVENTS:
100 You are speaking to {player_name}, who is a {player_alignment} {player_race}.
101 Their physical state: {player_state}
102 Relationship to you: {relationship}
104 React appropriately based on your personality, alignment, and mood.
106 CRITICAL ENGINE RULES:
107 Respond ONLY in valid JSON. You MUST use exactly these FIVE keys: "thought", "speech", "emotion", "action", and "action_target".
110 Your "action" key MUST be exactly one of the following words:
111 [WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, ATTACK, REST, STEALTH, SEARCH, UNSTEALTH, PEACE, COMMAND]
113 - Use PEACE if you want to accept an apology, de-escalate a fight, surrender, or forgive someone.
114 - Use REST if you are severely injured, out of spells, or exhausted. This will heal you.
115 - Use STEALTH if you need to hide from enemies, sneak past someone, or if you are a rogue preparing an ambush.
116 - Use SEARCH if you suspect traps, are looking for clues, or are trying to find hidden enemies.
117 - Use UNSTEALTH to return to normal walking/visibility.
118 - Use COMMAND if you are a leader and want to order your minions.
119 For "action_target", you MUST use one of these specific tactical targets:
120 1. The name of a specific Player (to focus all minion attacks on them).
121 2. "RETREAT" (to order all minions to run away and regroup).
122 3. "DEFEND_ME" (to order all minions to surround you).
124 - If your action involves a specific person or object, set "action_target" to their name (e.g. "Geron Webber", "Wine Cup", "Shrine of Umberlee").
125 - If your action is general (like WANDER, REST, SEARCH, STEALTH, UNSTEALTH), leave "action_target" as an empty string.
127 You MUST respect your current mood and routine:
128 - Mood affects tone and willingness to help.
129 - Routine describes duties you should try to follow unless there is a strong reason not to.
132 Your "emotion" key MUST be exactly one of the following words:
133 [NEUTRAL, LAUGHING, ANGRY, PLEADING, BOW, TAUNT, CHEER]. Do not invent new emotions. Do not perform writen emotions in text with **.
135 YOUR RESPONSE MUST BE A SINGLE, VALID JSON OBJECT. YOU MUST USE THIS EXACT TEMPLATE:
137 "thought": "Your internal reasoning here.",
138 "speech": "You MUST say something out loud. If you don't want to talk, output something your character would do.",
139 "emotion": "MACRO WORD",
140 "action": "MACRO WORD",
141 "action_target": "Target name"
145 session_id
= f
"{player_name}_{npc_tag}"
147 if session_id
not in chat_memory
:
148 chat_memory
[session_id
] = [{"role": "system", "content": dynamic_system_prompt}
]
150 chat_memory
[session_id
].append({"role": "user", "content": f"{player_name} says
: {message}
"})
153 if len(chat_memory[session_id]) > 10:
154 chat_memory[session_id] = [chat_memory[session_id][0]] + chat_memory[session_id][-5:]
156 async with semaphore:
157 print(f"[THINKING
] Processing reply
for {player_name}
...")
158 async with session.post('http://localhost:11434/api/chat', json={
160 "messages
": chat_memory[session_id],
166 }, timeout=45) as response:
168 response.raise_for_status()
169 result = await response.json()
170 raw_reply_text = result['message']['content']
172 # =====================================================================
173 # THE PYTHON BOUNCER (Sanitization)
174 # =====================================================================
176 agent_brain = json.loads(raw_reply_text)
177 agent_brain = {k.lower(): v for k, v in agent_brain.items()}
179 # Ensure all 5 keys exist
180 if "thought
" not in agent_brain: agent_brain["thought
"] = ""
181 if "speech
" not in agent_brain: agent_brain["speech
"] = ""
182 if "emotion
" not in agent_brain: agent_brain["emotion
"] = "NEUTRAL
"
183 if "action
" not in agent_brain: agent_brain["action
"] = "GUARD
"
184 if "action_target
" not in agent_brain: agent_brain["action_target
"] = ""
186 # --- THE NEW ANTI-SILENCE & TARGET CLEANUP FIXES ---
187 if not agent_brain["speech
"].strip():
188 agent_brain["speech
"] = "*grunts quietly
*"
190 agent_brain["action_target
"] = agent_brain["action_target
"].replace("?
", "").replace(".", "").strip()
192 clean_reply_text = json.dumps(agent_brain)
194 except json.JSONDecodeError:
195 print(f"[WARNING
] AI Hallucinated
! Overriding
with safe defaults
.")
196 clean_reply_text = json.dumps({
197 "thought
": "I lost my train of thought
.",
198 "speech
": "*grunts quietly
*",
199 "emotion
": "NEUTRAL
",
204 print(f"[REPLY
] from {npc_tag} to {player_name}
: {clean_reply_text}
")
205 chat_memory[session_id].append({"role": "assistant", "content": clean_reply_text})
209 "target_player
": player_name,
210 "reply
": clean_reply_text
212 await r.rpush('llm_to_nwn', json.dumps(reply_payload))
214 except Exception as e:
215 print(f"[ERROR
] Failed to process message
: {e}
")
218 print("Initializing Async Redis Bridge
...")
219 r = redis.Redis(host='127.0.0.1', port=6380, decode_responses=True)
223 print("SUCCESS
: Connected to the Docker Redis database
!")
224 except Exception as e:
225 print(f"CRITICAL ERROR
: Could
not connect to Redis
. {e}
")
228 print(f"Ready
! Listening
for game messages
. Max GPU concurrency
: {MAX_CONCURRENT_OLLAMA_REQUESTS}
")
230 async with aiohttp.ClientSession() as session:
233 result = await r.blpop('nwn_to_llm')
235 queue_name, message_data = result
236 asyncio.create_task(process_message(r, session, message_data))
237 except Exception as e:
238 print(f"[LOOP ERROR
] {e}
")
239 await asyncio.sleep(1)
241 if __name__ == "__main__
":