From 11c9cf58a3fcd16cc8d6aa6da4f1f625eaef5891 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 22 May 2025 23:26:03 -0300 Subject: [PATCH 1/6] Added table and general functions for stats system --- bot/db_utils.py | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ db.py | 9 ++++ 2 files changed, 115 insertions(+) diff --git a/bot/db_utils.py b/bot/db_utils.py index bb80d98..62e7f15 100644 --- a/bot/db_utils.py +++ b/bot/db_utils.py @@ -4,12 +4,17 @@ import config DB_PATH = config.DB_PATH +# Database functions + def get_db_connection(): '''Creates a connection to the database''' conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn + +# User functions + def get_or_create_user(username): '''Retrieves an ID for a given user, if the user does not exist, it will be created.''' @@ -32,6 +37,9 @@ def get_or_create_user(username): conn.close() return user_id + +# Gameplay functions + def add_pull(user_id, character_id): '''Creates a pull in the database''' conn = get_db_connection() @@ -40,6 +48,9 @@ def add_pull(user_id, character_id): conn.commit() conn.close() + +# Configuration + def get_config(key): '''Reads the value for a specified config key from the db''' conn = get_db_connection() @@ -56,3 +67,98 @@ def set_config(key, value): cur.execute("INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)", (key, value)) conn.commit() conn.close() + + +# Character stat functions + +def add_character_stats(character_id, stats): + ''' + Adds or updates character stats in the character_stats table. + `stats` should be a dictionary like {'power': 5, 'charm': 3} + ''' + if not stats: + return + + conn = get_db_connection() + cur = conn.cursor() + + columns = ', '.join(stats.keys()) + placeholders = ', '.join(['?'] * len(stats)) + updates = ', '.join([f"{col}=excluded.{col}" for col in stats.keys()]) + + values = list(stats.values()) + + sql = f''' + INSERT INTO character_stats (character_id, {columns}) + VALUES (?, {placeholders}) + ON CONFLICT(character_id) DO UPDATE SET {updates} + ''' + cur.execute(sql, [character_id] + values) + conn.commit() + conn.close() + + +def update_character_stat(character_id, stat_name, value): + '''Updates a single stat field for a character''' + conn = get_db_connection() + cur = conn.cursor() + cur.execute(f''' + UPDATE character_stats SET {stat_name} = ? WHERE character_id = ? + ''', (value, character_id)) + conn.commit() + conn.close() + +def get_character_stats(character_id): + '''Retrieves all stats for a single character dynamically''' + conn = get_db_connection() + conn.row_factory = sqlite3.Row # Enables dict-style access to rows + cur = conn.cursor() + cur.execute('SELECT * FROM character_stats WHERE character_id = ?', (character_id,)) + row = cur.fetchone() + conn.close() + + if row: + return {key: row[key] for key in row.keys() if key != 'character_id'} + else: + return {} + +def get_character_stat(character_id, stat_name): + '''Retrieves a single stat value for a character''' + if stat_name not in ('power', 'charm'): + raise ValueError("Invalid stat name") + conn = get_db_connection() + cur = conn.cursor() + cur.execute(f'SELECT {stat_name} FROM character_stats WHERE character_id = ?', (character_id,)) + row = cur.fetchone() + conn.close() + return row[0] if row else 0 + +def get_stats_for_multiple_characters(character_ids): + ''' + Retrieves stats for a list of character IDs. + Returns a dictionary of character_id -> {stat_name: value, ...} + ''' + if not character_ids: + return {} + + placeholders = ','.join('?' for _ in character_ids) + query = f''' + SELECT * + FROM character_stats + WHERE character_id IN ({placeholders}) + ''' + + conn = get_db_connection() + cur = conn.cursor() + cur.execute(query, character_ids) + rows = cur.fetchall() + col_names = [desc[0] for desc in cur.description] + conn.close() + + result = {} + for row in rows: + character_id = row[0] + stats = dict(zip(col_names[1:], row[1:])) # Skip character_id + result[character_id] = stats + + return result diff --git a/db.py b/db.py index 63d0b43..93d96ea 100644 --- a/db.py +++ b/db.py @@ -41,6 +41,15 @@ cursor.execute(""" ) """) +cursor.execute(''' +CREATE TABLE IF NOT EXISTS character_stats ( + character_id INTEGER PRIMARY KEY, + power INTEGER NOT NULL, + charm INTEGER NOT NULL, + FOREIGN KEY(character_id) REFERENCES characters(id) +) +''') + """ # Insert example characters into the database if they don't already exist characters = [ ('Murakami-san', 1, 0.35), -- 2.36.2 From 237f17f40d6d6b2e2c5be0c59699ea1d71a9b2d1 Mon Sep 17 00:00:00 2001 From: VD15 Date: Fri, 23 May 2025 23:44:03 +0100 Subject: [PATCH 2/6] Add a roll timeout to the bot --- bot/add_character.py | 7 +-- bot/config.py | 24 ++++---- bot/db_utils.py | 10 +++- bot/response.py | 130 ++++++++++++++++++++++++++++++------------- example_config.ini | 34 ++++++----- 5 files changed, 132 insertions(+), 73 deletions(-) diff --git a/bot/add_character.py b/bot/add_character.py index 68528c3..aae3fb3 100644 --- a/bot/add_character.py +++ b/bot/add_character.py @@ -32,7 +32,7 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl raise ValueError("Image URL must be provided.") # Download image - response = requests.get(image_url, stream=True) + response = requests.get(image_url, stream=True, timeout=30) if response.status_code != 200: raise RuntimeError(f"Failed to download image from {image_url}") @@ -55,9 +55,6 @@ def add_character(name: str, rarity: int, weight: float, image_url: str) -> tupl character_id = cur.lastrowid return character_id, file_id - - except Exception as e: - raise finally: if 'conn' in locals(): - conn.close() \ No newline at end of file + conn.close() diff --git a/bot/config.py b/bot/config.py index 5b73661..643aeb1 100644 --- a/bot/config.py +++ b/bot/config.py @@ -4,22 +4,20 @@ config = configparser.ConfigParser() config.read('config.ini') # Username for the bot -USER = config['application']['BotUser'] - +USER = config['credentials']['User'] # API key for the bot -KEY = config['application']['ApiKey'] +KEY = config['credentials']['Token'] # Bot's Misskey instance URL -INSTANCE = config['application']['InstanceUrl'] - -# SQLite Database location -DB_PATH = config['application']['DatabaseLocation'] - -# Extra stuff for control of the bot +INSTANCE = config['credentials']['Instance'] # TODO: move this to db # Fedi handles in the traditional 'user@domain.tld' style, allows these users -# to use extra admin exclusive commands with the bot''' -ADMINS = config['application']['DefaultAdmins'] +# to use extra admin exclusive commands with the bot +ADMINS = config['application']['DefaultAdmins'] +# SQLite Database location +DB_PATH = config['application']['DatabaseLocation'] + +NOTIFICATION_POLL_INTERVAL = int(config['notification']['PollInterval']) +NOTIFICATION_BATCH_SIZE = int(config['notification']['BatchSize']) -NOTIFICATION_POLL_INTERVAL = int(config['application']['NotificationPollInterval']) -NOTIFICATION_BATCH_SIZE = int(config['application']['NotificationBatchSize']) +GACHA_ROLL_INTERVAL = int(config['gacha']['RollInterval']) diff --git a/bot/db_utils.py b/bot/db_utils.py index 62e7f15..584856b 100644 --- a/bot/db_utils.py +++ b/bot/db_utils.py @@ -1,5 +1,4 @@ import sqlite3 -import random import config DB_PATH = config.DB_PATH @@ -48,6 +47,15 @@ def add_pull(user_id, character_id): conn.commit() conn.close() +def get_last_rolled_at(user_id): + '''Gets the timestamp when the user last rolled''' + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT timestamp FROM pulls WHERE user_id = ? ORDER BY timestamp DESC", \ + (user_id,)) + row = cur.fetchone() + conn.close() + return row[0] if row else None # Configuration diff --git a/bot/response.py b/bot/response.py index 2c58c43..b009344 100644 --- a/bot/response.py +++ b/bot/response.py @@ -1,6 +1,8 @@ import random -from db_utils import get_or_create_user, add_pull, get_db_connection +from datetime import datetime, timedelta, timezone +from db_utils import get_or_create_user, add_pull, get_db_connection, get_last_rolled_at from add_character import add_character +from config import GACHA_ROLL_INTERVAL def get_character(): ''' Gets a random character from the database''' @@ -18,54 +20,104 @@ def get_character(): return chosen['id'], chosen['name'], chosen['file_id'], chosen['rarity'] +def do_roll(full_user): + '''Determines whether the user can roll, then pulls a random character''' + user_id = get_or_create_user(full_user) + + # Get date of user's last roll + date = get_last_rolled_at(user_id) + + # No date means it's users first roll + if date: + # SQLite timestamps returned by the DB are always in UTC + # Below timestamps are to be converted to UTC + prev = datetime.strptime(date + '+0000', '%Y-%m-%d %H:%M:%S%z') + now = datetime.now(timezone.utc) + + time_since_last_roll = now - prev + roll_interval = timedelta(seconds=GACHA_ROLL_INTERVAL) + duration = roll_interval - time_since_last_roll + + # User needs to wait before they can roll again + if time_since_last_roll < roll_interval: + remaining_duration = None + if duration.seconds > 3600: + remaining_duration = f'{-(duration.seconds // -3600)} hours' + elif duration.seconds > 60: + remaining_duration = f'{-(duration.seconds // -60)} minutes' + else: + remaining_duration = f'{duration.seconds} seconds' + + return f'{full_user} ⏱️ Please wait another {remaining_duration} before rolling again.' + + character_id, character_name, file_id, rarity = get_character() + + if not character_id: + return f'{full_user} Uwaaa... something went wrong! No characters found. 😿' + + add_pull(user_id,character_id) + stars = '⭐️' * rarity + return([f"@{full_user} 🎲 Congrats! You rolled {stars} **{character_name}**\n\ + She's all yours now~ 💖✨",[file_id]]) + def is_float(val): + '''Returns true if `val` can be converted to a float''' try: float(val) return True except ValueError: return False +def do_create(full_user, arguments, note_obj): + '''Creates a character''' + # Example call from bot logic + image_url = note_obj.get('files', [{}])[0].get('url') if note_obj.get('files') else None + if not image_url: + return f'{full_user}{full_user} You need an image to create a character, dumbass.' + + if len(arguments) != 3: + return '{full_user}Please specify the following attributes in order: \ + name, rarity, drop weighting' + + if not (arguments[1].isnumeric() and 1 <= int(arguments[1]) <= 5): + return f'{full_user}Invalid rarity: \'{arguments[1]}\' must be a number between 1 and 5' + + if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0): + return f'{full_user}Invalid drop weight: \'{arguments[2]}\' \ + must be a decimal value between 0.0 and 1.0' + + character_id, file_id = add_character( + name=arguments[0], + rarity=int(arguments[1]), + weight=float(arguments[2]), + image_url=image_url + ) + return([f'{full_user}Added {arguments[0]}, ID {character_id}.',[file_id]]) + + +def do_help(full_user): + '''Provides a list of commands that the bot can do.''' + return f'{full_user} Here\'s what I can do:\n \ + - `roll` Pulls a random character.\ + - `create ` Creates a character using a given image.\ + - `help` Shows this message' + +def do_invalid_command(command, full_user): + '''Generic response when an unknown or invalid command is sent''' + return f'{full_user} Unrecognised command: {command}\n\ + Message \'help\' to get a list of valid commands' def generate_response(parsed_command): - '''Given a command with arguments, processes the game state and returns a response''' command, full_user, arguments, note_obj = parsed_command - - if command == "roll": - user_id = get_or_create_user(full_user) - character_id, character_name, file_id, rarity = get_character() - - if not character_id: - #TODO: Can't have tuples of a single element - # Return these as a dict or object instead. - return(f"@{full_user} Uwaaa... something went wrong! No characters found. 😿") - - add_pull(user_id,character_id) - stars = '⭐️' * rarity - return([f"@{full_user} 🎲 Congrats! You rolled {stars} **{character_name}**\nShe's all yours now~ 💖✨",[file_id]]) - - if command == "create": - # Example call from bot logic - image_url = note_obj.get("files", [{}])[0].get("url") if note_obj.get("files") else None - if not image_url: - return "You need an image to create a character, dumbass." - - if len(arguments) != 3: - return "Please specify the following attributes in order: name, rarity, drop weighting" - - if not (arguments[1].isnumeric() and 1 <= int(arguments[1]) <= 5): - return f"Invalid rarity: '{arguments[1]}' must be a number between 1 and 5" - - if not (is_float(arguments[2]) and 0.0 < float(arguments[2]) <= 1.0): - return f"Invalid drop weight: '{arguments[2]}' must be a decimal value between 0.0 and 1.0" - - character_id, file_id = add_character( - name=arguments[0], - rarity=int(arguments[1]), - weight=float(arguments[2]), - image_url=image_url - ) - return([f"Added {arguments[0]}, ID {character_id}.",[file_id]]) - return None + match command: + case 'roll': + return do_roll(full_user) + case 'create': + return do_create(full_user, arguments, note_obj) + case 'help': + return do_help(command) + case _: + return do_invalid_command(command, full_user) diff --git a/example_config.ini b/example_config.ini index 44cc2f9..d7f1c14 100644 --- a/example_config.ini +++ b/example_config.ini @@ -1,23 +1,27 @@ ; Rename me to config.ini and put your values in here [application] -; Full fedi handle of the bot user -BotUser = @bot@example.tld - -; API key for the bot -; Generate one by going to Settings > API > Generate access token -ApiKey = abcdefghijklmnopqrstuvwxyz012345 - -; Fully qualified URL of the instance hosting the bot -InstanceUrl = http://example.tld - ; Comma separated list of fedi handles for any administrator users -DefaultAdmins = ['admin@example.tld'] - +; More can be added through the application +DefaultAdmins = ['admin@example.tld'] ; SQLite Database location DatabaseLocation = ./gacha_game.db +[gacha] +; Number of seconds players have to wait between rolls +RollInterval = 72000 + +[notification] ; Number of seconds to sleep while awaiting new notifications -NotificationPollInterval = 5 +PollInterval = 5 +; Number of notifications to process at once (max 100) +BatchSize = 10 + +[credentials] +; Fully qualified URL of the instance hosting the bot +Instance = http://example.tld +; Full fedi handle of the bot user +User = @bot@example.tld +; API key for the bot +; Generate one by going to Settings > API > Generate access token +Token = abcdefghijklmnopqrstuvwxyz012345 -; Number of notifications to process at once (limit 100) -NotificationBatchSize = 10 -- 2.36.2 From 74e4c86d02f5a2ee83c0f116e21784abbc661f5d Mon Sep 17 00:00:00 2001 From: w Date: Thu, 22 May 2025 23:26:03 -0300 Subject: [PATCH 3/6] Added table and general functions for stats system --- bot/db_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/db_utils.py b/bot/db_utils.py index 584856b..402d0af 100644 --- a/bot/db_utils.py +++ b/bot/db_utils.py @@ -59,6 +59,8 @@ def get_last_rolled_at(user_id): # Configuration +# Configuration + def get_config(key): '''Reads the value for a specified config key from the db''' conn = get_db_connection() -- 2.36.2 From 3a2033a0254e9c7c80f5ff23938bc6370b736cb8 Mon Sep 17 00:00:00 2001 From: w Date: Sat, 24 May 2025 17:54:15 -0300 Subject: [PATCH 4/6] Add a roll timeout to the bot --- bot/db_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/db_utils.py b/bot/db_utils.py index 402d0af..584856b 100644 --- a/bot/db_utils.py +++ b/bot/db_utils.py @@ -59,8 +59,6 @@ def get_last_rolled_at(user_id): # Configuration -# Configuration - def get_config(key): '''Reads the value for a specified config key from the db''' conn = get_db_connection() -- 2.36.2 From 3b7f2006ef3dea991f6910e49a487453fe5bacae Mon Sep 17 00:00:00 2001 From: w Date: Wed, 28 May 2025 23:44:59 -0300 Subject: [PATCH 5/6] unifying character and stats insertion, generalize get_character --- bot/db_utils.py | 86 +++++++++++-------------------------------------- 1 file changed, 18 insertions(+), 68 deletions(-) diff --git a/bot/db_utils.py b/bot/db_utils.py index 2d030cb..a236980 100644 --- a/bot/db_utils.py +++ b/bot/db_utils.py @@ -47,13 +47,27 @@ def get_or_create_user(username): user_id = CURSOR.lastrowid return user_id -def insert_character(name: str, rarity: int, weight: float, file_id: str) -> int: +def insert_character(name: str, rarity: int, weight: float, file_id: str, stats: dict) -> int: '''Inserts a character''' CURSOR.execute( 'INSERT INTO characters (name, rarity, weight, file_id) VALUES (?, ?, ?, ?)', (name, rarity, weight, file_id) ) character_id = CURSOR.lastrowid + + # Insert stats + columns = ', '.join(stats.keys()) + placeholders = ', '.join(['?'] * len(stats)) + updates = ', '.join([f"{col}=excluded.{col}" for col in stats.keys()]) + values = list(stats.values()) + + sql = f''' + INSERT INTO character_stats (character_id, {columns}) + VALUES (?, {placeholders}) + ON CONFLICT(character_id) DO UPDATE SET {updates} + ''' + CURSOR.execute(sql, [character_id] + values) + return character_id if character_id else 0 def insert_pull(user_id, character_id): @@ -85,69 +99,7 @@ def set_config(key, value): # Character stat functions -def add_character_stats(character_id, stats): - ''' - Adds or updates character stats in the character_stats table. - `stats` should be a dictionary like {'power': 5, 'charm': 3} - ''' - if not stats: - return - - conn = get_db_connection() - cur = conn.cursor() - - columns = ', '.join(stats.keys()) - placeholders = ', '.join(['?'] * len(stats)) - updates = ', '.join([f"{col}=excluded.{col}" for col in stats.keys()]) - - values = list(stats.values()) - - sql = f''' - INSERT INTO character_stats (character_id, {columns}) - VALUES (?, {placeholders}) - ON CONFLICT(character_id) DO UPDATE SET {updates} - ''' - cur.execute(sql, [character_id] + values) - conn.commit() - conn.close() - - -def update_character_stat(character_id, stat_name, value): - '''Updates a single stat field for a character''' - conn = get_db_connection() - cur = conn.cursor() - cur.execute(f''' - UPDATE character_stats SET {stat_name} = ? WHERE character_id = ? - ''', (value, character_id)) - conn.commit() - conn.close() - -def get_character_stats(character_id): - '''Retrieves all stats for a single character dynamically''' - conn = get_db_connection() - conn.row_factory = sqlite3.Row # Enables dict-style access to rows - cur = conn.cursor() - cur.execute('SELECT * FROM character_stats WHERE character_id = ?', (character_id,)) - row = cur.fetchone() - conn.close() - - if row: - return {key: row[key] for key in row.keys() if key != 'character_id'} - else: - return {} - -def get_character_stat(character_id, stat_name): - '''Retrieves a single stat value for a character''' - if stat_name not in ('power', 'charm'): - raise ValueError("Invalid stat name") - conn = get_db_connection() - cur = conn.cursor() - cur.execute(f'SELECT {stat_name} FROM character_stats WHERE character_id = ?', (character_id,)) - row = cur.fetchone() - conn.close() - return row[0] if row else 0 - -def get_stats_for_multiple_characters(character_ids): +def get_characters(character_ids): ''' Retrieves stats for a list of character IDs. Returns a dictionary of character_id -> {stat_name: value, ...} @@ -162,12 +114,10 @@ def get_stats_for_multiple_characters(character_ids): WHERE character_id IN ({placeholders}) ''' - conn = get_db_connection() - cur = conn.cursor() - cur.execute(query, character_ids) + + CURSOR.execute(query, character_ids) rows = cur.fetchall() col_names = [desc[0] for desc in cur.description] - conn.close() result = {} for row in rows: -- 2.36.2 From 9d98299264ebfc0f1632938cb4ef5fdefc80abf4 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 28 May 2025 23:52:04 -0300 Subject: [PATCH 6/6] expanding character_stats --- db.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/db.py b/db.py index 52596c4..3849481 100644 --- a/db.py +++ b/db.py @@ -44,8 +44,8 @@ cursor.execute(""" cursor.execute(''' CREATE TABLE IF NOT EXISTS character_stats ( character_id INTEGER PRIMARY KEY, - power INTEGER NOT NULL, - charm INTEGER NOT NULL, + power INTEGER NOT NULL DEFAULT abs(random() % 9999), + charm INTEGER NOT NULL DEFAULT abs(random() % 9999),, FOREIGN KEY(character_id) REFERENCES characters(id) ) ''') -- 2.36.2