Add NPC interaction system with memory and quest generation

- Introduced a new NPC system with dynamic NPCs and conversation handling.
- Implemented NPC memory using SQLite to log conversations and manage relationships.
- Added commands for talking to NPCs, listing available NPCs, and generating quests.
- Updated database schema to support NPC conversations and relationships.
- Refactored code structure to separate concerns into cogs and handlers.
This commit is contained in:
2025-09-30 14:12:22 +02:00
parent c8980f785f
commit 7e76353c6a
24 changed files with 7468 additions and 14 deletions
Binary file not shown.
Binary file not shown.
Executable
+62
View File
@@ -0,0 +1,62 @@
# main.py
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv
import threading
import itertools
from npc_memory import NPCMemory
from npc_handler import NPCHandler
from cogs.npc import NPCCog
class MyNewHelp(commands.MinimalHelpCommand):
async def send_pages(self):
destination = self.get_destination()
for page in self.paginator.pages:
emby = discord.Embed(description=page)
await destination.send(embed=emby)
class Client(commands.Bot):
def __init__(self):
super().__init__(
command_prefix=self.iterate_prefix("py"),
strip_after_prefix=True,
case_insensitive=True,
intents=discord.Intents.all(),
help_command=MyNewHelp(),
)
def iterate_prefix(self, prefix):
prefixes = list(map(''.join, itertools.product(*zip(prefix.upper(), prefix.lower()))))
print(prefixes)
return prefixes
async def setup_hook(self): # overwriting a handler
cogs_folder = f"{os.path.abspath(os.path.dirname(__file__))}/cogs"
for filename in os.listdir(cogs_folder):
if filename.endswith(".py"):
try:
await self.load_extension(f"cogs.{filename[:-3]}")
except Exception as e:
print(f"Failed to load {filename}: {e}")
memory = NPCMemory()
npc_handler = NPCHandler(memory)
await self.add_cog(NPCCog(self, npc_handler))
await self.tree.sync()
print("Loaded cogs")
def main():
load_dotenv()
client = Client()
token = os.getenv("TOKEN")
if token is not None:
client.run(token)
else:
print("Token is missing.")
if __name__ == "__main__":
main()
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
import discord
from discord.ext import commands
class NPCCog(commands.Cog):
def __init__(self, bot, npc_handler):
self.bot = bot
self.npc_handler = npc_handler
self.player_contexts = {}
@commands.command(name="talk")
async def talk_to_npc(self, ctx, npc_name: str, *, message: str):
player_id = str(ctx.author.id)
if player_id not in self.player_contexts:
self.player_contexts[player_id] = {
'id': player_id,
'level': 1,
'reputation': 0,
'recent_actions': [],
'location': 'Newhaven'
}
response = self.npc_handler.chat_with_npc(
npc_name, message, self.player_contexts[player_id]
)
embed = discord.Embed(
title=f"{npc_name} says...",
description=response,
color=discord.Color.blue()
)
await ctx.send(embed=embed)
@commands.command(name="npcs")
async def list_npcs(self, ctx):
npc_list = "\n".join([f"{name}" for name in self.npc_handler.npcs.keys()])
await ctx.send(f"Available NPCs:\n{npc_list}")
@commands.command(name="quest")
async def get_quest(self, ctx, npc_name: str):
player_id = str(ctx.author.id)
player_level = self.player_contexts.get(player_id, {}).get('level', 1)
quest = self.npc_handler.generate_quest(npc_name, player_level)
if quest:
embed = discord.Embed(
title=quest["title"],
description=quest["description"],
color=discord.Color.gold()
)
embed.add_field(name="Reward", value=quest["reward"])
embed.add_field(name="Difficulty", value=quest["difficulty"])
await ctx.send(embed=embed)
else:
await ctx.send("NPC not found or unable to generate quest.")
+50
View File
@@ -0,0 +1,50 @@
import json
import random
NPC_DATABASE = {
"barkeep_boris": {
"personality": "Jovial and gossipy, knows everyone's business",
"backstory": "Retired adventurer who settled down after losing party to dragon",
"quirks": ["Offers free drinks to good storytellers", "Hates elves"]
},
"mad_wizard": {
"personality": "Eccentric and forgetful, brilliant but scattered",
"backstory": "Expelled from wizard college for 'creative' spellcasting",
"quirks": ["Speaks to imaginary familiar", "Offers dangerous experimental potions"]
}
}
class DynamicNPC:
def __init__(self, name, data):
self.name = name
self.personality = data["personality"]
self.backstory = data["backstory"]
self.quirks = data["quirks"]
class NPCHandler:
def __init__(self, memory):
self.memory = memory
self.npcs = {name: DynamicNPC(name, data) for name, data in NPC_DATABASE.items()}
def chat_with_npc(self, npc_name, message, player_context):
npc = self.npcs.get(npc_name)
if not npc:
return "That NPC doesn't exist."
# Simple response logic (replace with LLM call as needed)
response = f"{npc.name} ({npc.personality}): I heard you say '{message}'."
self.memory.log_conversation(npc_name, player_context['id'], message, response)
self.memory.update_affinity(npc_name, player_context['id'], 1)
return response
def generate_quest(self, npc_name, player_level):
npc = self.npcs.get(npc_name)
if not npc:
return None
# Replace this with LLM call if available
quest = {
"title": f"{npc.name}'s Request",
"description": f"Help {npc.name} with a task suitable for level {player_level}.",
"reward": f"{random.randint(10, 100)} coins",
"difficulty": random.choice(["easy", "medium", "hard"])
}
return quest
+46
View File
@@ -0,0 +1,46 @@
import sqlite3
class NPCMemory:
def __init__(self):
self.conn = sqlite3.connect('npc_memory.db')
self.create_tables()
def create_tables(self):
self.conn.execute('''
CREATE TABLE IF NOT EXISTS npc_conversations (
npc_id TEXT,
player_id TEXT,
message TEXT,
response TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
self.conn.execute('''
CREATE TABLE IF NOT EXISTS npc_relationships (
npc_id TEXT,
player_id TEXT,
affinity INTEGER DEFAULT 0,
last_interaction DATETIME
)
''')
self.conn.commit()
def log_conversation(self, npc_id, player_id, message, response):
self.conn.execute(
'INSERT INTO npc_conversations (npc_id, player_id, message, response) VALUES (?, ?, ?, ?)',
(npc_id, player_id, message, response)
)
self.conn.commit()
def update_affinity(self, npc_id, player_id, delta):
cur = self.conn.cursor()
cur.execute('SELECT affinity FROM npc_relationships WHERE npc_id=? AND player_id=?', (npc_id, player_id))
row = cur.fetchone()
if row:
new_affinity = row[0] + delta
cur.execute('UPDATE npc_relationships SET affinity=?, last_interaction=CURRENT_TIMESTAMP WHERE npc_id=? AND player_id=?',
(new_affinity, npc_id, player_id))
else:
cur.execute('INSERT INTO npc_relationships (npc_id, player_id, affinity, last_interaction) VALUES (?, ?, ?, CURRENT_TIMESTAMP)',
(npc_id, player_id, delta))
self.conn.commit()
+9 -2
View File
@@ -5,10 +5,12 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from web.app import app from web.app import app
import threading import threading
import itertools
def run_web(): def run_web():
app.run(debug=False, host="0.0.0.0", port=5000) app.run(debug=False, host="0.0.0.0", port=8080)
return
class MyNewHelp(commands.MinimalHelpCommand): class MyNewHelp(commands.MinimalHelpCommand):
@@ -22,12 +24,17 @@ class MyNewHelp(commands.MinimalHelpCommand):
class Client(commands.Bot): class Client(commands.Bot):
def __init__(self): def __init__(self):
super().__init__( super().__init__(
command_prefix=["pY ", "PY ", "Py ", "py "], command_prefix=self.iterate_prefix("py"),
strip_after_prefix=True, strip_after_prefix=True,
case_insensitive=True, case_insensitive=True,
intents=discord.Intents.all(), intents=discord.Intents.all(),
help_command=MyNewHelp(), help_command=MyNewHelp(),
) )
def iterate_prefix(self, prefix):
prefixes = list(map(''.join, itertools.product(*zip(prefix.upper(), prefix.lower()))))
print(prefixes)
return prefixes
async def setup_hook(self): # overwriting a handler async def setup_hook(self): # overwriting a handler
cogs_folder = f"{os.path.abspath(os.path.dirname(__file__))}/cogs" cogs_folder = f"{os.path.abspath(os.path.dirname(__file__))}/cogs"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -3,7 +3,7 @@ from random import shuffle, choices, choice
from discord.ext import commands from discord.ext import commands
from utils.sql_commands import DatabaseManager from utils.sql_commands import DatabaseManager
from utils.bank_functions import bank_data, update_money from utils.bank_functions import bank_data, update_money
from datetime import datetime, timedelta from datetime import datetime
import asyncio import asyncio
import random import random
@@ -94,7 +94,7 @@ class Deck:
class Hand: class Hand:
def __init__(self, name, bet): def __init__(self, name:str, bet:int):
self.cards = [] self.cards = []
self.value = 0 self.value = 0
self.aces = 0 self.aces = 0
+4 -5
View File
@@ -1,4 +1,3 @@
import discord
from discord.ext import commands from discord.ext import commands
import smtplib import smtplib
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@@ -10,7 +9,7 @@ from datetime import datetime
class Mail(commands.Cog): class Mail(commands.Cog):
def __init__(self, client): def __init__(self, client:commands.Bot):
self.client = client self.client = client
load_dotenv() load_dotenv()
from utils.sql_commands import DatabaseManager from utils.sql_commands import DatabaseManager
@@ -19,7 +18,7 @@ class Mail(commands.Cog):
@commands.is_owner() @commands.is_owner()
@commands.command(name="mail_feedback") @commands.command(name="mail_feedback")
async def mail(self, ctx): async def mail(self, ctx:commands.Context[commands.Bot]):
password = getenv("EMAILPASS") password = getenv("EMAILPASS")
username = getenv("EMAILUSER") username = getenv("EMAILUSER")
server = getenv("EMAILSERVER") server = getenv("EMAILSERVER")
@@ -44,7 +43,7 @@ class Mail(commands.Cog):
msg["Subject"] = "Py feedback" msg["Subject"] = "Py feedback"
# Fetch feedback from the database # Fetch feedback from the database
feedback_rows = self.db.fetch_all("SELECT * FROM feedback") feedback_rows:list[dict[str,str]] = self.db.fetch_all("SELECT * FROM feedback")
all_feedback = "" all_feedback = ""
for i, row in enumerate(feedback_rows, 1): for i, row in enumerate(feedback_rows, 1):
content = html.escape(row["CONTENT"]) content = html.escape(row["CONTENT"])
@@ -131,5 +130,5 @@ class Mail(commands.Cog):
await ctx.reply(f"Failed to send mail: {e}", delete_after=5) await ctx.reply(f"Failed to send mail: {e}", delete_after=5)
async def setup(client): async def setup(client:commands.Bot):
await client.add_cog(Mail(client)) await client.add_cog(Mail(client))
+1 -1
View File
@@ -241,5 +241,5 @@ class XP(commands.Cog):
return image_bytes return image_bytes
async def setup(client): async def setup(client: commands.Bot):
await client.add_cog(XP(client)) await client.add_cog(XP(client))
+7199
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"messages": {
"601579326714019840": {
"total": 32,
"commands": 27,
"non_commands": 5
}
},
"commands": {
"purge": 2,
"mail_feedback": 1,
"help": 5,
"nuke": 1,
"ping": 1,
"top": 1,
"stats": 1,
"poker": 7,
"balance": 2,
"daily": 1,
"withdraw": 1,
"leaderboard": 1,
"afk": 1,
"afklist": 1,
"whois": 1
},
"channels": {
"bot": 32
},
"guilds": {
"Plex": 32
},
"total_messages": 0,
"command_messages": 0,
"non_command_messages": 0
}
+1 -1
View File
@@ -1 +1 @@
1749735051.5259159 1759232942.275409
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -203,6 +203,8 @@ class DatabaseManager:
query = f"{query} ({', '.join(keys)}) VALUES ({placeholders})" query = f"{query} ({', '.join(keys)}) VALUES ({placeholders})"
values = [tuple(data.values()) for data in params] values = [tuple(data.values()) for data in params]
connection = None
cursor = None
try: try:
connection = self.get_connection() connection = self.get_connection()
cursor = connection.cursor() cursor = connection.cursor()
@@ -213,9 +215,12 @@ class DatabaseManager:
) )
except mysql.connector.Error as err: except mysql.connector.Error as err:
logger.error(f"Bulk insert failed: {err}") logger.error(f"Bulk insert failed: {err}")
if connection:
connection.rollback() # Roll back on error connection.rollback() # Roll back on error
finally: finally:
if cursor:
cursor.close() cursor.close()
if connection:
connection.close() connection.close()
def delete(self, table_name: str, condition: dict) -> None: def delete(self, table_name: str, condition: dict) -> None:
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.