4 import redis
.asyncio
as redis
10 # --- CONFIGURATION ---
11 MAX_CONCURRENT_OLLAMA_REQUESTS
= 3
12 ALLOW_TEXT_EMOTES
= False
14 # =====================================================================
15 # 1. INITIALIZE VECTOR DATABASES (Lore & Episodic Memory)
16 # =====================================================================
17 print("Initializing ChromaDB Vector Databases...")
18 chroma_client
= chromadb
.PersistentClient(path
="./asl_vectordb")
21 lore_collection
= chroma_client
.get_or_create_collection(name
="world_lore")
23 # The Episodic Memory Database
24 memory_collection
= chroma_client
.get_or_create_collection(name
="episodic_memories")
25 memory_queue
= asyncio
.Queue()
27 if os
.path
.exists("asl_lore.md"):
28 if lore_collection
.count() == 0:
29 print("[VECTOR DB] Reading asl_lore.md and vectorizing chunks...")
30 with open("asl_lore.md", "r", encoding
="utf-8") as f
:
33 lore_chunks
= [chunk
.strip() for chunk
in raw_lore
.split('\n\n') if chunk
.strip()]
36 chunk_ids
= [f
"lore_{i}" for i
in range(len(lore_chunks
))]
37 lore_collection
.add(documents
=lore_chunks
, ids
=chunk_ids
)
38 print(f
"[VECTOR DB] Successfully stored {len(lore_chunks)} lore chunks!")
40 print("[WARNING] asl_lore.md not found.")
42 semaphore
= asyncio
.Semaphore(MAX_CONCURRENT_OLLAMA_REQUESTS
)
45 # =====================================================================
46 # BACKGROUND MEMORY SUMMARIZER (The "Dream State")
47 # =====================================================================
48 async def memory_summarizer_worker(session
):
49 print("[BACKGROUND] Memory Summarizer Worker is active.")
51 job
= await memory_queue
.get()
52 session_id
= job
['session_id']
53 player_name
= job
['player_name']
54 npc_tag
= job
['npc_tag']
55 chat_log
= job
['chat_log']
57 prompt
= f
"Summarize the key events, facts, and the emotional tone of this conversation snippet between {player_name} and {npc_tag}. Keep it to 2 brief sentences in the past tense.\nConversation Log:\n{chat_log}"
60 print(f
"[MEMORY DB] Generating background memory for {player_name} and {npc_tag}...")
61 async with session
.post('http://localhost:11434/api/generate', json
={
62 "model": "llama3", # Changed back to llama3 from gemma4 based on previous setup
69 result
= await response
.json()
70 summary
= result
['response'].strip()
73 doc_id
= f
"{session_id}_{int(time.time())}"
74 memory_collection
.add(
76 metadatas
=[{"session_id": session_id}
],
79 print(f
"[MEMORY DB] Memory Saved: {summary}")
81 except Exception as e
:
82 print(f
"[MEMORY ERROR] Failed to summarize memory: {e}")
84 memory_queue
.task_done()
86 # =====================================================================
87 # MAIN MESSAGE PROCESSOR
88 # =====================================================================
89 async def process_message(r
, session
, message_data
):
91 data
= json
.loads(message_data
)
93 # --- Extract Base Contexts ---
94 player_name
= data
.get('player', data
.get('target_player', 'Unknown'))
95 npc_tag
= data
.get('npc_tag', 'UnknownNPC')
96 message
= data
.get('message', '')
98 if not ALLOW_TEXT_EMOTES
:
99 message
= re
.sub(r
'\*.*?\*', '', message
).strip()
101 player_race
= data
.get('player_race', 'Unknown')
102 player_alignment
= data
.get('player_alignment', 'Unknown')
103 nearby_players
= data
.get('nearby_players', '')
104 nearby_npcs
= data
.get('nearby_npcs', '')
106 npc_persona
= data
.get('persona', 'You are a generic citizen.')
107 npc_profession
= data
.get('profession', 'Commoner')
108 npc_mood
= data
.get('mood', 'Neutral')
109 npc_secret
= data
.get('secret', '')
111 npc_alignment
= data
.get('npc_alignment', 'True Neutral')
112 npc_gender
= data
.get('npc_gender', 'Unknown')
113 npc_race
= data
.get('npc_race', 'Creature')
114 npc_routine
= data
.get('npc_routine', '')
116 player_state
= data
.get('player_state', 'Relaxed and unarmed.')
117 world_state
= data
.get('world_state', 'Nothing of note is happening.')
118 npc_health
= data
.get('npc_health', 'Healthy and uninjured.')
119 relationship
= data
.get('relationship', 'Neutral or Friendly.')
120 location_context
= data
.get('location_context', 'You are in a generic area.')
122 # Core Strategy Flag (1: Agent, 2: Villain, 3: Maestro, 4: Shrine)
123 llm_strategy
= int(data
.get('llm_strategy', 1))
125 # --- Sub-Context Strings ---
126 group_context
= f
"Be aware that these other players are listening nearby: {nearby_players}." if nearby_players
else ""
127 puppet_context
= f
"Nearby generic NPCs you can CONVERSE with: {nearby_npcs}" if nearby_npcs
else ""
128 secret_context
= f
"YOUR SECRET (Reveal only if players are persuasive): {npc_secret}" if npc_secret
else ""
129 routine_context
= f
"YOUR REQUIRED ROUTINE: {npc_routine}" if npc_routine
else ""
131 session_id
= f
"{player_name}_{npc_tag}"
133 # =====================================================================
134 # DUAL RAG QUERY (Lore + Memories)
135 # =====================================================================
136 search_query
= f
"{location_context} {message}"
137 retrieved_lore
= "No specific local lore currently relevant."
140 if lore_collection
.count() > 0:
141 results
= lore_collection
.query(query_texts
=[search_query
], n_results
=1)
142 if results
['documents'] and results
['documents'][0]:
143 retrieved_lore
= f
"- {results['documents'][0][0]}"
145 if memory_collection
.count() > 0:
146 mem_results
= memory_collection
.query(
147 query_texts
=[search_query
], n_results
=2, where
={"session_id": session_id}
149 if mem_results
['documents'] and mem_results
['documents'][0]:
150 formatted_mems
= "\n- ".join(mem_results
['documents'][0])
151 past_memories
= f
"\nPAST MEMORIES OF {player_name}:\n- {formatted_mems}"
153 # =====================================================================
154 # STRATEGY-SPECIFIC PROMPT COMPILER
155 # =====================================================================
160 if llm_strategy
== 1:
161 # STRATEGY 1: The Autonomous Agent
162 strategy_rules
= f
"ROLE: You are an interactive, living NPC. You actively respond to players.\nGOALS: React to their words, use the environment, and establish your personality.\nSPECIAL CAPABILITIES: You can offer quests or open your merchant store if asked."
163 action_macros
= "[WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, OPEN_STORE, GIVE_QUEST, CONVERSE]"
164 target_context
= f
"CURRENT TARGET: You are speaking to {player_name}, a {player_alignment} {player_race}.\nTheir physical state: {player_state}\nRelationship to you: {relationship}\n{group_context}"
166 elif llm_strategy
== 2:
167 # STRATEGY 2: The Villain Commander
168 strategy_rules
= f
"ROLE: You are a hostile faction commander.\nGOALS: Evaluate the tactical situation. If you are dying, you MUST use REST to heal or PEACE to surrender. Command your minions strategically!"
169 action_macros
= "[ATTACK, COMMAND, RETREAT, REST, PEACE, USE_OBJECT, TAUNT]"
170 target_context
= f
"TACTICAL TARGET: You are evaluating {player_name}, a {player_alignment} {player_race}.\nTheir physical state: {player_state}\nRelationship to you: {relationship}\n{group_context}"
172 elif llm_strategy
== 3:
173 # STRATEGY 3: The Maestro (Puppeteer)
174 strategy_rules
= "ROLE: You are an ambient Maestro NPC. You DO NOT interact with players. You only talk to other NPCs to make the world feel alive.\nGOALS: Observe the environment and initiate conversations with the generic NPCs listed in your context. Ignore players entirely."
175 action_macros
= "[WANDER, INTERACT, USE_OBJECT, CONVERSE]"
176 target_context
= "CURRENT TARGET: You are ignoring players and focusing on ambient life. Do not address players."
178 elif llm_strategy
== 4:
179 # STRATEGY 4: The Shrine
180 strategy_rules
= "ROLE: You are an ancient, inanimate magical shrine.\nGOALS: Speak cryptically. If the player meets your conditions or asks the right questions, grant them a quest."
181 action_macros
= "[GLOW, GIVE_QUEST, SILENCE]"
182 target_context
= f
"CURRENT TARGET: You are evaluating the soul of {player_name}, a {player_alignment} {player_race}.\nTheir physical state: {player_state}\n{group_context}"
184 # =====================================================================
185 # COMPILE THE FINAL DYNAMIC SYSTEM PROMPT
186 # =====================================================================
187 dynamic_system_prompt
= f
"""
191 ROLEPLAY STATUS & TRAITS:
192 - Race & Gender: {npc_gender} {npc_race}
193 - Profession: {npc_profession}
194 - Alignment: {npc_alignment}
195 - Conversational Charisma: Low/Gruff unless otherwise specified.
196 - Current Mood: {npc_mood}
197 - Current Physical State: {npc_health}
201 CURRENT LOCATION: {location_context}
204 RELEVANT WORLD KNOWLEDGE:
208 CURRENT WORLD RUMORS/EVENTS:
212 React appropriately based on your personality, alignment, and current strategy rules.
214 CRITICAL ENGINE RULES:
215 Respond ONLY in valid JSON. You MUST use exactly these keys: "thought", "speech", "emotion", "action", "action_target", and "target_speech".
218 Your "action" key MUST be exactly one of the following words:
221 - Use CONVERSE if you want to initiate a back-and-forth dialogue with a standard, unintelligent NPC. You will write their response for them in "target_speech".
223 YOUR RESPONSE MUST BE A SINGLE, VALID JSON OBJECT. YOU MUST USE THIS EXACT TEMPLATE:
225 "thought": "Your internal reasoning here.",
226 "speech": "What YOU say out loud.",
227 "emotion": "MACRO WORD",
228 "action": "MACRO WORD",
229 "action_target": "Target name",
230 "target_speech": "If action is CONVERSE, write what the target NPC replies back to you here. Otherwise, leave blank."
234 if session_id
not in chat_memory
:
235 chat_memory
[session_id
] = [{"role": "system", "content": dynamic_system_prompt}
]
237 chat_memory
[session_id
][0] = {"role": "system", "content": dynamic_system_prompt}
239 chat_memory
[session_id
].append({"role": "user", "content": f"{player_name} says
: {message}
"})
241 # =====================================================================
242 # MEMORY EXTRACTION TRIGGER
243 # =====================================================================
244 if len(chat_memory[session_id]) > 10:
245 messages_to_summarize = chat_memory[session_id][1:6]
246 chat_log_str = "\n".join([m['content'] for m in messages_to_summarize])
248 await memory_queue.put({
249 'session_id': session_id,
250 'player_name': player_name,
252 'chat_log': chat_log_str
255 chat_memory[session_id] = [chat_memory[session_id][0]] + chat_memory[session_id][-5:]
257 # =====================================================================
259 # =====================================================================
260 async with semaphore:
261 print(f"[THINKING
] Processing reply
for {player_name}
(Strategy {llm_strategy}
)...")
262 async with session.post('http://localhost:11434/api/chat', json={
264 "messages
": chat_memory[session_id],
270 }, timeout=45) as response:
272 response.raise_for_status()
273 result = await response.json()
274 raw_reply_text = result['message']['content']
276 # =====================================================================
278 # =====================================================================
280 agent_brain = json.loads(raw_reply_text)
281 agent_brain = {k.lower(): v for k, v in agent_brain.items()}
283 if "thought
" not in agent_brain: agent_brain["thought
"] = ""
284 if "speech
" not in agent_brain: agent_brain["speech
"] = ""
285 if "emotion
" not in agent_brain: agent_brain["emotion
"] = "NEUTRAL
"
286 if "action
" not in agent_brain: agent_brain["action
"] = "WANDER
"
287 if "action_target
" not in agent_brain: agent_brain["action_target
"] = ""
288 if "target_speech
" not in agent_brain: agent_brain["target_speech
"] = ""
290 if not agent_brain["speech
"].strip():
291 agent_brain["speech
"] = "*grunts quietly
*"
293 agent_brain["action_target
"] = agent_brain["action_target
"].replace("?
", "").replace(".", "").strip()
295 clean_reply_text = json.dumps(agent_brain)
297 except json.JSONDecodeError:
298 print(f"[WARNING
] AI Hallucinated
! Overriding
with safe defaults
.")
299 clean_reply_text = json.dumps({
300 "thought
": "I lost my train of thought
.",
301 "speech
": "*grunts quietly
*",
302 "emotion
": "NEUTRAL
",
308 print(f"[REPLY
] from {npc_tag} to {player_name}
: {clean_reply_text}
")
309 chat_memory[session_id].append({"role": "assistant", "content": clean_reply_text})
313 "target_player
": player_name,
314 "reply
": clean_reply_text
316 await r.rpush('llm_to_nwn', json.dumps(reply_payload))
318 except Exception as e:
319 print(f"[ERROR
] Failed to process message
: {e}
")
322 print("Initializing Async Redis Bridge
...")
323 r = redis.Redis(host='127.0.0.1', port=6380, decode_responses=True)
327 print("SUCCESS
: Connected to the Docker Redis database
!")
328 except Exception as e:
329 print(f"CRITICAL ERROR
: Could
not connect to Redis
. {e}
")
332 print(f"Ready
! Listening
for game messages
. Max GPU concurrency
: {MAX_CONCURRENT_OLLAMA_REQUESTS}
")
334 async with aiohttp.ClientSession() as session:
335 asyncio.create_task(memory_summarizer_worker(session))
339 result = await r.blpop('nwn_to_llm')
341 queue_name, message_data = result
342 asyncio.create_task(process_message(r, session, message_data))
343 except Exception as e:
344 print(f"[LOOP ERROR
] {e}
")
345 await asyncio.sleep(1)
347 if __name__ == "__main__
":