Implement magazine system and inventory management
- Add level-based magazine system (3 mags at L1, 2 at L3-5, 1 at L6-8) - Replace ammo/chargers with current_ammo/magazines/bullets_per_magazine - Add inventory system for storing and using shop items - Add Magazine item to shop (15 XP, adds 1 magazine) - Auto-migrate existing players from old ammo system - Auto-update magazines when players level up - Fix method name bugs (get_player_level -> calculate_player_level)
This commit is contained in:
52
levels.json
52
levels.json
@@ -1,79 +1,95 @@
|
|||||||
{
|
{
|
||||||
"level_calculation": {
|
"level_calculation": {
|
||||||
"method": "total_ducks",
|
"method": "xp",
|
||||||
"description": "Level based on total ducks interacted with (shot + befriended)"
|
"description": "Level based on XP earned from hunting and befriending ducks"
|
||||||
},
|
},
|
||||||
"levels": {
|
"levels": {
|
||||||
"1": {
|
"1": {
|
||||||
"name": "Duck Novice",
|
"name": "Duck Novice",
|
||||||
"min_ducks": 0,
|
"min_xp": 0,
|
||||||
"max_ducks": 9,
|
"max_xp": 49,
|
||||||
"befriend_success_rate": 85,
|
"befriend_success_rate": 85,
|
||||||
"accuracy_modifier": 5,
|
"accuracy_modifier": 5,
|
||||||
"duck_spawn_speed_modifier": 1.0,
|
"duck_spawn_speed_modifier": 1.0,
|
||||||
|
"magazines": 3,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "Just starting out, ducks are trusting and easier to hit"
|
"description": "Just starting out, ducks are trusting and easier to hit"
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"name": "Pond Visitor",
|
"name": "Pond Visitor",
|
||||||
"min_ducks": 10,
|
"min_xp": 50,
|
||||||
"max_ducks": 24,
|
"max_xp": 149,
|
||||||
"befriend_success_rate": 80,
|
"befriend_success_rate": 80,
|
||||||
"accuracy_modifier": 0,
|
"accuracy_modifier": 0,
|
||||||
"duck_spawn_speed_modifier": 1.0,
|
"duck_spawn_speed_modifier": 1.0,
|
||||||
|
"magazines": 3,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "Ducks are getting wary of you"
|
"description": "Ducks are getting wary of you"
|
||||||
},
|
},
|
||||||
"3": {
|
"3": {
|
||||||
"name": "Duck Hunter",
|
"name": "Duck Hunter",
|
||||||
"min_ducks": 25,
|
"min_xp": 150,
|
||||||
"max_ducks": 49,
|
"max_xp": 299,
|
||||||
"befriend_success_rate": 75,
|
"befriend_success_rate": 75,
|
||||||
"accuracy_modifier": -5,
|
"accuracy_modifier": -5,
|
||||||
"duck_spawn_speed_modifier": 0.9,
|
"duck_spawn_speed_modifier": 0.9,
|
||||||
|
"magazines": 2,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "Your reputation precedes you, ducks are more cautious"
|
"description": "Your reputation precedes you, ducks are more cautious"
|
||||||
},
|
},
|
||||||
"4": {
|
"4": {
|
||||||
"name": "Wetland Stalker",
|
"name": "Wetland Stalker",
|
||||||
"min_ducks": 50,
|
"min_xp": 300,
|
||||||
"max_ducks": 99,
|
"max_xp": 599,
|
||||||
"befriend_success_rate": 70,
|
"befriend_success_rate": 70,
|
||||||
"accuracy_modifier": -10,
|
"accuracy_modifier": -10,
|
||||||
"duck_spawn_speed_modifier": 0.8,
|
"duck_spawn_speed_modifier": 0.8,
|
||||||
|
"magazines": 2,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "Ducks flee at your approach, spawns are less frequent"
|
"description": "Ducks flee at your approach, spawns are less frequent"
|
||||||
},
|
},
|
||||||
"5": {
|
"5": {
|
||||||
"name": "Apex Predator",
|
"name": "Apex Predator",
|
||||||
"min_ducks": 100,
|
"min_xp": 600,
|
||||||
"max_ducks": 199,
|
"max_xp": 999,
|
||||||
"befriend_success_rate": 65,
|
"befriend_success_rate": 65,
|
||||||
"accuracy_modifier": -15,
|
"accuracy_modifier": -15,
|
||||||
"duck_spawn_speed_modifier": 0.7,
|
"duck_spawn_speed_modifier": 0.7,
|
||||||
|
"magazines": 2,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "You're feared throughout the pond, ducks are very elusive"
|
"description": "You're feared throughout the pond, ducks are very elusive"
|
||||||
},
|
},
|
||||||
"6": {
|
"6": {
|
||||||
"name": "Duck Whisperer",
|
"name": "Duck Whisperer",
|
||||||
"min_ducks": 200,
|
"min_xp": 1000,
|
||||||
"max_ducks": 399,
|
"max_xp": 1999,
|
||||||
"befriend_success_rate": 60,
|
"befriend_success_rate": 60,
|
||||||
"accuracy_modifier": -20,
|
"accuracy_modifier": -20,
|
||||||
"duck_spawn_speed_modifier": 0.6,
|
"duck_spawn_speed_modifier": 0.6,
|
||||||
|
"magazines": 1,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "Only the bravest ducks dare show themselves"
|
"description": "Only the bravest ducks dare show themselves"
|
||||||
},
|
},
|
||||||
"7": {
|
"7": {
|
||||||
"name": "Legendary Hunter",
|
"name": "Legendary Hunter",
|
||||||
"min_ducks": 400,
|
"min_xp": 2000,
|
||||||
"max_ducks": 999,
|
"max_xp": 4999,
|
||||||
"befriend_success_rate": 55,
|
"befriend_success_rate": 55,
|
||||||
"accuracy_modifier": -25,
|
"accuracy_modifier": -25,
|
||||||
"duck_spawn_speed_modifier": 0.5,
|
"duck_spawn_speed_modifier": 0.5,
|
||||||
|
"magazines": 1,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "Duck folklore speaks of your prowess, they're extremely rare"
|
"description": "Duck folklore speaks of your prowess, they're extremely rare"
|
||||||
},
|
},
|
||||||
"8": {
|
"8": {
|
||||||
"name": "Duck Deity",
|
"name": "Duck Deity",
|
||||||
"min_ducks": 1000,
|
"min_xp": 5000,
|
||||||
"max_ducks": 999999,
|
"max_xp": 999999,
|
||||||
"befriend_success_rate": 50,
|
"befriend_success_rate": 50,
|
||||||
"accuracy_modifier": -30,
|
"accuracy_modifier": -30,
|
||||||
"duck_spawn_speed_modifier": 0.4,
|
"duck_spawn_speed_modifier": 0.4,
|
||||||
|
"magazines": 1,
|
||||||
|
"bullets_per_magazine": 6,
|
||||||
"description": "You've transcended mortal hunting, ducks are mythically scarce"
|
"description": "You've transcended mortal hunting, ducks are mythically scarce"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,32 @@
|
|||||||
{
|
{
|
||||||
"duck_spawn": [
|
"duck_spawn": [
|
||||||
"・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}",
|
"・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}",
|
||||||
"・゜゜・。。・゜゜\\_O> {yellow}*flap flap*"
|
"・゜゜・。。・゜゜\\_o< {light_grey}quack~{reset}",
|
||||||
|
"・゜゜・。。・゜゜\\_O> {bold}*flap flap*{reset}"
|
||||||
],
|
],
|
||||||
"duck_flies_away": "The {cyan}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`",
|
"duck_flies_away": "The {bold}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`",
|
||||||
"bang_hit": "{nick} > {red}*BANG*{reset} You shot the {cyan}duck{reset}! [{green}+{xp_gained} xp{reset}] [Total ducks: {blue}{ducks_shot}{reset}]",
|
"bang_hit": "{nick} > {green}*BANG*{reset} You shot the duck! [{green}+{xp_gained} xp{reset}] [Total ducks: {bold}{ducks_shot}{reset}]",
|
||||||
"bang_miss": "{nick} > {red}*BANG*{reset} You missed the {cyan}duck{reset}!",
|
"bang_miss": "{nick} > {red}*BANG*{reset} You missed the {cyan}duck{reset}!",
|
||||||
"bang_no_duck": "{nick} > {red}*BANG*{reset} What did you shoot at? There is {red}no duck{reset} in the area... [{red}GUN CONFISCATED{reset}]",
|
"bang_no_duck": "{nick} > {red}*BANG*{reset} What did you shoot at? There is {red}no duck{reset} in the area... [{red}GUN CONFISCATED{reset}]",
|
||||||
"bang_no_ammo": "{nick} > {orange}*click*{reset} You're out of ammo! Use {blue}!reload{reset}",
|
"bang_no_ammo": "{nick} > {orange}*click*{reset} You're out of ammo! Use {blue}!reload{reset}",
|
||||||
"bang_not_armed": "{nick} > You are {red}not armed{reset}.",
|
"bang_not_armed": "{nick} > You are {red}not armed{reset}.",
|
||||||
"bef_success": "{nick} > {green}*befriend*{reset} You befriended the {cyan}duck{reset}! [{green}+{xp_gained} xp{reset}] [Ducks befriended: {pink}{ducks_befriended}{reset}]",
|
"bef_success": "{nick} > {orange}*befriend*{reset} You befriended the duck! [{green}+{xp_gained} xp{reset}] [Ducks befriended: {bold}{ducks_befriended}{reset}]",
|
||||||
"bef_failed": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} doesn't trust you and {yellow}flies away{reset}...",
|
"bef_failed": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} doesn't trust you and {yellow}flies away{reset}...",
|
||||||
"bef_no_duck": "{nick} > {pink}*gentle approach*{reset} There is {red}no duck{reset} to befriend in the area...",
|
"bef_no_duck": "{nick} > {pink}*gentle approach*{reset} There is {red}no duck{reset} to befriend in the area...",
|
||||||
"bef_duck_shot": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} is {red}already dead{reset}! You can't befriend it now...",
|
"bef_duck_shot": "{nick} > {pink}*gentle approach*{reset} The {cyan}duck{reset} is {red}already dead{reset}! You can't befriend it now...",
|
||||||
"reload_success": "{nick} > {orange}*click*{reset} Reloaded! [Ammo: {green}{ammo}{reset}/{green}{max_ammo}{reset}] [Chargers: {blue}{chargers}{reset}]",
|
"reload_success": "{nick} > {orange}*click*{reset} New magazine loaded! [Ammo: {green}{ammo}{reset}/{green}{max_ammo}{reset}] [Spare magazines: {blue}{chargers}{reset}]",
|
||||||
"reload_already_loaded": "{nick} > Your gun is {green}already loaded{reset}!",
|
"reload_already_loaded": "{nick} > Your gun is {green}already loaded{reset}!",
|
||||||
"reload_no_chargers": "{nick} > You're out of {red}chargers{reset}!",
|
"reload_no_chargers": "{nick} > You're out of {red}spare magazines{reset}!",
|
||||||
"reload_not_armed": "{nick} > You are {red}not armed{reset}.",
|
"reload_not_armed": "{nick} > You are {red}not armed{reset}.",
|
||||||
"shop_display": "DuckHunt Shop: {items} | You have {green}{xp} XP{reset}",
|
"shop_display": "DuckHunt Shop: {items} | You have {green}{xp} XP{reset}",
|
||||||
"shop_item_format": "({blue}{id}{reset}) {cyan}{name}{reset} - {green}{price} XP{reset}",
|
"shop_item_format": "({blue}{id}{reset}) {cyan}{name}{reset} - {green}{price} XP{reset}",
|
||||||
"help_header": "{blue}DuckHunt Commands:{reset}",
|
"help_header": "{blue}DuckHunt Commands:{reset}",
|
||||||
"help_user_commands": "{blue}!bang{reset} - Shoot at ducks | {blue}!bef{reset} - Befriend ducks | {blue}!reload{reset} - Reload your gun | {blue}!shop{reset} - View the shop",
|
"help_user_commands": "{blue}!bang{reset} - Shoot at ducks | {blue}!bef{reset} - Befriend ducks | {blue}!reload{reset} - Reload your gun | {blue}!shop{reset} - View/buy from shop | {blue}!duckstats{reset} - View your stats and items | {blue}!use{reset} - Use inventory items",
|
||||||
"help_help_command": "{blue}!duckhelp{reset} - Show this help",
|
"help_help_command": "{blue}!duckhelp{reset} - Show this help",
|
||||||
"help_admin_commands": "{red}Admin:{reset} {blue}!rearm <player>{reset} | {blue}!disarm <player>{reset} | {blue}!ignore <player>{reset} | {blue}!unignore <player>{reset} | {blue}!ducklaunch{reset} | {blue}!reloadshop{reset}",
|
"help_admin_commands": "{red}Admin:{reset} {blue}!rearm <player>{reset} | {blue}!disarm <player>{reset} | {blue}!ignore <player>{reset} | {blue}!unignore <player>{reset} | {blue}!ducklaunch{reset}",
|
||||||
"admin_rearm_player": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been rearmed by {blue}{admin}{reset}",
|
"admin_rearm_player": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been rearmed by {blue}{admin}{reset}",
|
||||||
"admin_rearm_all": "[{red}ADMIN{reset}] All players have been rearmed by {blue}{admin}{reset}",
|
"admin_rearm_all": "[{red}ADMIN{reset}] All players have been rearmed by {blue}{admin}{reset}",
|
||||||
|
"admin_rearm_self": "[{red}ADMIN{reset}] {blue}{admin}{reset} has rearmed themselves",
|
||||||
"admin_disarm": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been disarmed by {blue}{admin}{reset}",
|
"admin_disarm": "[{red}ADMIN{reset}] {cyan}{target}{reset} has been disarmed by {blue}{admin}{reset}",
|
||||||
"admin_ignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is now ignored by {blue}{admin}{reset}",
|
"admin_ignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is now ignored by {blue}{admin}{reset}",
|
||||||
"admin_unignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is no longer ignored by {blue}{admin}{reset}",
|
"admin_unignore": "[{red}ADMIN{reset}] {cyan}{target}{reset} is no longer ignored by {blue}{admin}{reset}",
|
||||||
|
|||||||
17
shop.json
17
shop.json
@@ -8,18 +8,11 @@
|
|||||||
"amount": 1
|
"amount": 1
|
||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"name": "Accuracy Boost",
|
"name": "Magazine",
|
||||||
"price": 20,
|
"price": 15,
|
||||||
"description": "+10% accuracy",
|
"description": "1 extra magazine",
|
||||||
"type": "accuracy",
|
"type": "magazine",
|
||||||
"amount": 10
|
"amount": 1
|
||||||
},
|
|
||||||
"3": {
|
|
||||||
"name": "Lucky Charm",
|
|
||||||
"price": 30,
|
|
||||||
"description": "+5% duck spawn chance",
|
|
||||||
"type": "luck",
|
|
||||||
"amount": 5
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
37
src/db.py
37
src/db.py
@@ -64,10 +64,34 @@ class DuckDB:
|
|||||||
if nick_lower not in self.players:
|
if nick_lower not in self.players:
|
||||||
self.players[nick_lower] = self.create_player(nick)
|
self.players[nick_lower] = self.create_player(nick)
|
||||||
else:
|
else:
|
||||||
# Ensure existing players have new fields
|
# Ensure existing players have new fields and migrate from old system
|
||||||
player = self.players[nick_lower]
|
player = self.players[nick_lower]
|
||||||
if 'ducks_befriended' not in player:
|
if 'ducks_befriended' not in player:
|
||||||
player['ducks_befriended'] = 0
|
player['ducks_befriended'] = 0
|
||||||
|
if 'inventory' not in player:
|
||||||
|
player['inventory'] = {}
|
||||||
|
if 'temporary_effects' not in player:
|
||||||
|
player['temporary_effects'] = []
|
||||||
|
|
||||||
|
# Migrate from old ammo/chargers system to magazine system
|
||||||
|
if 'magazines' not in player:
|
||||||
|
# Convert old system: assume they had full magazines
|
||||||
|
old_ammo = player.get('ammo', 6)
|
||||||
|
old_chargers = player.get('chargers', 2)
|
||||||
|
|
||||||
|
player['current_ammo'] = old_ammo
|
||||||
|
player['magazines'] = old_chargers + 1 # +1 for current loaded magazine
|
||||||
|
player['bullets_per_magazine'] = 6
|
||||||
|
|
||||||
|
# Remove old fields
|
||||||
|
if 'ammo' in player:
|
||||||
|
del player['ammo']
|
||||||
|
if 'max_ammo' in player:
|
||||||
|
del player['max_ammo']
|
||||||
|
if 'chargers' in player:
|
||||||
|
del player['chargers']
|
||||||
|
if 'max_chargers' in player:
|
||||||
|
del player['max_chargers']
|
||||||
|
|
||||||
return self.players[nick_lower]
|
return self.players[nick_lower]
|
||||||
|
|
||||||
@@ -78,10 +102,11 @@ class DuckDB:
|
|||||||
'xp': 0,
|
'xp': 0,
|
||||||
'ducks_shot': 0,
|
'ducks_shot': 0,
|
||||||
'ducks_befriended': 0,
|
'ducks_befriended': 0,
|
||||||
'ammo': 6,
|
'current_ammo': 6, # Bullets in current magazine
|
||||||
'max_ammo': 6,
|
'magazines': 3, # Total magazines (including current)
|
||||||
'chargers': 2,
|
'bullets_per_magazine': 6, # Bullets per magazine
|
||||||
'max_chargers': 2,
|
|
||||||
'accuracy': 65,
|
'accuracy': 65,
|
||||||
'gun_confiscated': False
|
'gun_confiscated': False,
|
||||||
|
'inventory': {}, # {item_id: quantity}
|
||||||
|
'temporary_effects': [] # List of temporary effects
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import ssl
|
import ssl
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@@ -15,6 +14,7 @@ from .db import DuckDB
|
|||||||
from .game import DuckGame
|
from .game import DuckGame
|
||||||
from .sasl import SASLHandler
|
from .sasl import SASLHandler
|
||||||
from .shop import ShopManager
|
from .shop import ShopManager
|
||||||
|
from .levels import LevelManager
|
||||||
|
|
||||||
|
|
||||||
class DuckHuntBot:
|
class DuckHuntBot:
|
||||||
@@ -41,6 +41,10 @@ class DuckHuntBot:
|
|||||||
shop_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'shop.json')
|
shop_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'shop.json')
|
||||||
self.shop = ShopManager(shop_file)
|
self.shop = ShopManager(shop_file)
|
||||||
|
|
||||||
|
# Initialize level manager
|
||||||
|
levels_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'levels.json')
|
||||||
|
self.levels = LevelManager(levels_file)
|
||||||
|
|
||||||
def get_config(self, path, default=None):
|
def get_config(self, path, default=None):
|
||||||
"""Get configuration value using dot notation"""
|
"""Get configuration value using dot notation"""
|
||||||
keys = path.split('.')
|
keys = path.split('.')
|
||||||
@@ -59,12 +63,39 @@ class DuckHuntBot:
|
|||||||
nick = user.split('!')[0].lower()
|
nick = user.split('!')[0].lower()
|
||||||
return nick in self.admins
|
return nick in self.admins
|
||||||
|
|
||||||
|
def _handle_single_target_admin_command(self, args, usage_message_key, action_func, success_message_key, nick, channel):
|
||||||
|
"""Helper for admin commands that target a single player"""
|
||||||
|
if not args:
|
||||||
|
message = self.messages.get(usage_message_key)
|
||||||
|
self.send_message(channel, message)
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = args[0].lower()
|
||||||
|
player = self.db.get_player(target)
|
||||||
|
action_func(player)
|
||||||
|
|
||||||
|
message = self.messages.get(success_message_key, target=target, admin=nick)
|
||||||
|
self.send_message(channel, message)
|
||||||
|
self.db.save_database()
|
||||||
|
return True
|
||||||
|
|
||||||
def setup_signal_handlers(self):
|
def setup_signal_handlers(self):
|
||||||
"""Setup signal handlers for graceful shutdown"""
|
"""Setup signal handlers for immediate shutdown"""
|
||||||
def signal_handler(signum, frame):
|
def signal_handler(signum, frame):
|
||||||
signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
|
signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
|
||||||
self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), initiating graceful shutdown...")
|
self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), shutting down immediately...")
|
||||||
self.shutdown_requested = True
|
self.shutdown_requested = True
|
||||||
|
|
||||||
|
# Cancel all running tasks immediately
|
||||||
|
try:
|
||||||
|
# Get the current event loop and cancel all tasks
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
tasks = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
self.logger.info(f"🔄 Cancelled {len(tasks)} running tasks")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error cancelling tasks: {e}")
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
@@ -147,6 +178,10 @@ class DuckHuntBot:
|
|||||||
await self.handle_reload(nick, channel, player)
|
await self.handle_reload(nick, channel, player)
|
||||||
elif cmd == "shop":
|
elif cmd == "shop":
|
||||||
await self.handle_shop(nick, channel, player, args)
|
await self.handle_shop(nick, channel, player, args)
|
||||||
|
elif cmd == "duckstats":
|
||||||
|
await self.handle_duckstats(nick, channel, player)
|
||||||
|
elif cmd == "use":
|
||||||
|
await self.handle_use(nick, channel, player, args)
|
||||||
elif cmd == "duckhelp":
|
elif cmd == "duckhelp":
|
||||||
await self.handle_duckhelp(nick, channel, player)
|
await self.handle_duckhelp(nick, channel, player)
|
||||||
elif cmd == "rearm" and self.is_admin(user):
|
elif cmd == "rearm" and self.is_admin(user):
|
||||||
@@ -159,157 +194,66 @@ class DuckHuntBot:
|
|||||||
await self.handle_unignore(nick, channel, args)
|
await self.handle_unignore(nick, channel, args)
|
||||||
elif cmd == "ducklaunch" and self.is_admin(user):
|
elif cmd == "ducklaunch" and self.is_admin(user):
|
||||||
await self.handle_ducklaunch(nick, channel, args)
|
await self.handle_ducklaunch(nick, channel, args)
|
||||||
elif cmd == "reloadshop" and self.is_admin(user):
|
|
||||||
await self.handle_reloadshop(nick, channel, args)
|
|
||||||
|
|
||||||
async def handle_bang(self, nick, channel, player):
|
async def handle_bang(self, nick, channel, player):
|
||||||
"""Handle !bang command"""
|
"""Handle !bang command"""
|
||||||
# Check if gun is confiscated
|
result = self.game.shoot_duck(nick, channel, player)
|
||||||
if player.get('gun_confiscated', False):
|
message = self.messages.get(result['message_key'], **result['message_args'])
|
||||||
message = self.messages.get('bang_not_armed', nick=nick)
|
self.send_message(channel, message)
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check ammo
|
|
||||||
if player['ammo'] <= 0:
|
|
||||||
message = self.messages.get('bang_no_ammo', nick=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check for duck
|
|
||||||
if channel not in self.game.ducks or not self.game.ducks[channel]:
|
|
||||||
# Wild shot - gun confiscated
|
|
||||||
player['ammo'] -= 1
|
|
||||||
player['gun_confiscated'] = True
|
|
||||||
message = self.messages.get('bang_no_duck', nick=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
self.db.save_database()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Shoot at duck
|
|
||||||
player['ammo'] -= 1
|
|
||||||
|
|
||||||
# Calculate hit chance
|
|
||||||
hit_chance = player.get('accuracy', 65) / 100.0
|
|
||||||
if random.random() < hit_chance:
|
|
||||||
# Hit! Remove the duck
|
|
||||||
duck = self.game.ducks[channel].pop(0)
|
|
||||||
xp_gained = 10
|
|
||||||
player['xp'] = player.get('xp', 0) + xp_gained
|
|
||||||
player['ducks_shot'] = player.get('ducks_shot', 0) + 1
|
|
||||||
player['accuracy'] = min(player.get('accuracy', 65) + 1, 100)
|
|
||||||
|
|
||||||
message = self.messages.get('bang_hit',
|
|
||||||
nick=nick,
|
|
||||||
xp_gained=xp_gained,
|
|
||||||
ducks_shot=player['ducks_shot'])
|
|
||||||
self.send_message(channel, message)
|
|
||||||
else:
|
|
||||||
# Miss! Duck stays in the channel
|
|
||||||
player['accuracy'] = max(player.get('accuracy', 65) - 2, 10)
|
|
||||||
message = self.messages.get('bang_miss', nick=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
|
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
async def handle_bef(self, nick, channel, player):
|
async def handle_bef(self, nick, channel, player):
|
||||||
"""Handle !bef (befriend) command"""
|
"""Handle !bef (befriend) command"""
|
||||||
# Check for duck
|
result = self.game.befriend_duck(nick, channel, player)
|
||||||
if channel not in self.game.ducks or not self.game.ducks[channel]:
|
message = self.messages.get(result['message_key'], **result['message_args'])
|
||||||
message = self.messages.get('bef_no_duck', nick=nick)
|
self.send_message(channel, message)
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check befriend success rate from config (default 75%)
|
|
||||||
success_rate_config = self.get_config('befriend_success_rate', 75)
|
|
||||||
try:
|
|
||||||
success_rate = float(success_rate_config) / 100.0
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
success_rate = 0.75 # 75% default
|
|
||||||
|
|
||||||
if random.random() < success_rate:
|
|
||||||
# Success - befriend the duck
|
|
||||||
duck = self.game.ducks[channel].pop(0)
|
|
||||||
|
|
||||||
# Lower XP gain than shooting (5 instead of 10)
|
|
||||||
xp_gained = 5
|
|
||||||
player['xp'] = player.get('xp', 0) + xp_gained
|
|
||||||
player['ducks_befriended'] = player.get('ducks_befriended', 0) + 1
|
|
||||||
|
|
||||||
message = self.messages.get('bef_success',
|
|
||||||
nick=nick,
|
|
||||||
xp_gained=xp_gained,
|
|
||||||
ducks_befriended=player['ducks_befriended'])
|
|
||||||
self.send_message(channel, message)
|
|
||||||
else:
|
|
||||||
# Failure - duck flies away, remove from channel
|
|
||||||
duck = self.game.ducks[channel].pop(0)
|
|
||||||
|
|
||||||
message = self.messages.get('bef_failed', nick=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
|
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
async def handle_reload(self, nick, channel, player):
|
async def handle_reload(self, nick, channel, player):
|
||||||
"""Handle !reload command"""
|
"""Handle !reload command"""
|
||||||
if player.get('gun_confiscated', False):
|
result = self.game.reload_gun(nick, channel, player)
|
||||||
message = self.messages.get('reload_not_armed', nick=nick)
|
message = self.messages.get(result['message_key'], **result['message_args'])
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
if player['ammo'] >= player.get('max_ammo', 6):
|
|
||||||
message = self.messages.get('reload_already_loaded', nick=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
if player.get('chargers', 2) <= 0:
|
|
||||||
message = self.messages.get('reload_no_chargers', nick=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
player['ammo'] = player.get('max_ammo', 6)
|
|
||||||
player['chargers'] = player.get('chargers', 2) - 1
|
|
||||||
|
|
||||||
message = self.messages.get('reload_success',
|
|
||||||
nick=nick,
|
|
||||||
ammo=player['ammo'],
|
|
||||||
max_ammo=player.get('max_ammo', 6),
|
|
||||||
chargers=player['chargers'])
|
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
async def handle_shop(self, nick, channel, player, args=None):
|
async def handle_shop(self, nick, channel, player, args=None):
|
||||||
"""Handle !shop command"""
|
"""Handle !shop command"""
|
||||||
# Handle buying: !shop buy <item_id>
|
# Handle buying: !shop buy <item_id> [target] or !shop <item_id> [target]
|
||||||
if args and len(args) >= 2 and args[0].lower() == "buy":
|
if args and len(args) >= 1:
|
||||||
try:
|
# Check for "buy" subcommand or direct item ID
|
||||||
item_id = int(args[1])
|
start_idx = 0
|
||||||
await self.handle_shop_buy(nick, channel, player, item_id)
|
if args[0].lower() == "buy":
|
||||||
return
|
start_idx = 1
|
||||||
except (ValueError, IndexError):
|
|
||||||
message = self.messages.get('shop_buy_usage', nick=nick)
|
if len(args) > start_idx:
|
||||||
|
try:
|
||||||
|
item_id = int(args[start_idx])
|
||||||
|
target_nick = args[start_idx + 1] if len(args) > start_idx + 1 else None
|
||||||
|
|
||||||
|
# If no target specified, store in inventory. If target specified, use immediately.
|
||||||
|
store_in_inventory = target_nick is None
|
||||||
|
await self.handle_shop_buy(nick, channel, player, item_id, target_nick, store_in_inventory)
|
||||||
|
return
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
message = self.messages.get('shop_buy_usage', nick=nick)
|
||||||
|
self.send_message(channel, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display shop items using ShopManager
|
||||||
|
shop_text = self.shop.get_shop_display(player, self.messages)
|
||||||
|
self.send_message(channel, shop_text)
|
||||||
|
|
||||||
|
async def handle_shop_buy(self, nick, channel, player, item_id, target_nick=None, store_in_inventory=False):
|
||||||
|
"""Handle buying an item from the shop"""
|
||||||
|
target_player = None
|
||||||
|
|
||||||
|
# Get target player if specified
|
||||||
|
if target_nick:
|
||||||
|
target_player = self.db.get_player(target_nick)
|
||||||
|
if not target_player:
|
||||||
|
message = f"{nick} > Target player '{target_nick}' not found"
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Display shop items
|
|
||||||
items = []
|
|
||||||
for item_id, item in self.shop.get_items().items():
|
|
||||||
item_text = self.messages.get('shop_item_format',
|
|
||||||
id=item_id,
|
|
||||||
name=item['name'],
|
|
||||||
price=item['price'])
|
|
||||||
items.append(item_text)
|
|
||||||
|
|
||||||
shop_text = self.messages.get('shop_display',
|
|
||||||
items=" | ".join(items),
|
|
||||||
xp=player.get('xp', 0))
|
|
||||||
|
|
||||||
self.send_message(channel, shop_text)
|
|
||||||
|
|
||||||
async def handle_shop_buy(self, nick, channel, player, item_id):
|
|
||||||
"""Handle buying an item from the shop"""
|
|
||||||
# Use ShopManager to handle the purchase
|
# Use ShopManager to handle the purchase
|
||||||
result = self.shop.purchase_item(player, item_id)
|
result = self.shop.purchase_item(player, item_id, target_player, store_in_inventory)
|
||||||
|
|
||||||
if not result["success"]:
|
if not result["success"]:
|
||||||
# Handle different error types
|
# Handle different error types
|
||||||
@@ -321,6 +265,10 @@ class DuckHuntBot:
|
|||||||
item_name=result["item_name"],
|
item_name=result["item_name"],
|
||||||
price=result["price"],
|
price=result["price"],
|
||||||
current_xp=result["current_xp"])
|
current_xp=result["current_xp"])
|
||||||
|
elif result["error"] == "target_required":
|
||||||
|
message = f"{nick} > {result['message']}"
|
||||||
|
elif result["error"] == "invalid_storage":
|
||||||
|
message = f"{nick} > {result['message']}"
|
||||||
else:
|
else:
|
||||||
message = f"{nick} > Error: {result['message']}"
|
message = f"{nick} > Error: {result['message']}"
|
||||||
|
|
||||||
@@ -328,11 +276,17 @@ class DuckHuntBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Purchase successful
|
# Purchase successful
|
||||||
message = self.messages.get('shop_buy_success',
|
if result.get("stored_in_inventory"):
|
||||||
nick=nick,
|
message = f"{nick} > Successfully purchased {result['item_name']} for {result['price']} XP! Stored in inventory (x{result['inventory_count']}). Remaining XP: {result['remaining_xp']}"
|
||||||
item_name=result["item_name"],
|
elif result.get("target_affected"):
|
||||||
price=result["price"],
|
message = f"{nick} > Used {result['item_name']} on {target_nick}! Remaining XP: {result['remaining_xp']}"
|
||||||
remaining_xp=result["remaining_xp"])
|
else:
|
||||||
|
message = self.messages.get('shop_buy_success',
|
||||||
|
nick=nick,
|
||||||
|
item_name=result["item_name"],
|
||||||
|
price=result["price"],
|
||||||
|
remaining_xp=result["remaining_xp"])
|
||||||
|
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
self.db.save_database()
|
self.db.save_database()
|
||||||
|
|
||||||
@@ -351,71 +305,130 @@ class DuckHuntBot:
|
|||||||
for line in help_lines:
|
for line in help_lines:
|
||||||
self.send_message(channel, line)
|
self.send_message(channel, line)
|
||||||
|
|
||||||
|
async def handle_duckstats(self, nick, channel, player):
|
||||||
|
"""Handle !duckstats command - show player stats and inventory"""
|
||||||
|
# Get player level info
|
||||||
|
level_info = self.levels.get_player_level_info(player)
|
||||||
|
level = self.levels.calculate_player_level(player)
|
||||||
|
|
||||||
|
# Build stats message
|
||||||
|
stats_parts = [
|
||||||
|
f"Level {level} {level_info.get('name', 'Unknown')}",
|
||||||
|
f"XP: {player.get('xp', 0)}",
|
||||||
|
f"Ducks Shot: {player.get('ducks_shot', 0)}",
|
||||||
|
f"Ducks Befriended: {player.get('ducks_befriended', 0)}",
|
||||||
|
f"Accuracy: {player.get('accuracy', 65)}%",
|
||||||
|
f"Ammo: {player.get('current_ammo', 0)}/{player.get('bullets_per_magazine', 6)}",
|
||||||
|
f"Magazines: {player.get('magazines', 1)}"
|
||||||
|
]
|
||||||
|
|
||||||
|
stats_message = f"{nick} > Stats: {' | '.join(stats_parts)}"
|
||||||
|
self.send_message(channel, stats_message)
|
||||||
|
|
||||||
|
# Show inventory if not empty
|
||||||
|
inventory_info = self.shop.get_inventory_display(player)
|
||||||
|
if not inventory_info["empty"]:
|
||||||
|
items_text = []
|
||||||
|
for item in inventory_info["items"]:
|
||||||
|
items_text.append(f"{item['id']}: {item['name']} x{item['quantity']}")
|
||||||
|
inventory_message = f"{nick} > Inventory: {' | '.join(items_text)}"
|
||||||
|
self.send_message(channel, inventory_message)
|
||||||
|
|
||||||
|
async def handle_use(self, nick, channel, player, args):
|
||||||
|
"""Handle !use command"""
|
||||||
|
if not args:
|
||||||
|
message = f"{nick} > Usage: !use <item_id> [target]"
|
||||||
|
self.send_message(channel, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
item_id = int(args[0])
|
||||||
|
target_nick = args[1] if len(args) > 1 else None
|
||||||
|
target_player = None
|
||||||
|
|
||||||
|
# Get target player if specified
|
||||||
|
if target_nick:
|
||||||
|
target_player = self.db.get_player(target_nick)
|
||||||
|
if not target_player:
|
||||||
|
message = f"{nick} > Target player '{target_nick}' not found"
|
||||||
|
self.send_message(channel, message)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use item from inventory
|
||||||
|
result = self.shop.use_inventory_item(player, item_id, target_player)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
message = f"{nick} > {result['message']}"
|
||||||
|
else:
|
||||||
|
if result.get("target_affected"):
|
||||||
|
message = f"{nick} > Used {result['item_name']} on {target_nick}!"
|
||||||
|
else:
|
||||||
|
message = f"{nick} > Used {result['item_name']}!"
|
||||||
|
|
||||||
|
# Add remaining count if any
|
||||||
|
if result.get("remaining_in_inventory", 0) > 0:
|
||||||
|
message += f" ({result['remaining_in_inventory']} remaining)"
|
||||||
|
|
||||||
|
self.send_message(channel, message)
|
||||||
|
self.db.save_database()
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
message = f"{nick} > Invalid item ID. Use !duckstats to see your items."
|
||||||
|
self.send_message(channel, message)
|
||||||
|
|
||||||
async def handle_rearm(self, nick, channel, args):
|
async def handle_rearm(self, nick, channel, args):
|
||||||
"""Handle !rearm command (admin only)"""
|
"""Handle !rearm command (admin only)"""
|
||||||
if args:
|
if args:
|
||||||
target = args[0].lower()
|
target = args[0].lower()
|
||||||
player = self.db.get_player(target)
|
player = self.db.get_player(target)
|
||||||
player['gun_confiscated'] = False
|
player['gun_confiscated'] = False
|
||||||
player['ammo'] = player.get('max_ammo', 6)
|
|
||||||
player['chargers'] = 2
|
# Update magazines based on player level
|
||||||
|
self.levels.update_player_magazines(player)
|
||||||
|
player['current_ammo'] = player.get('bullets_per_magazine', 6)
|
||||||
|
|
||||||
message = self.messages.get('admin_rearm_player', target=target, admin=nick)
|
message = self.messages.get('admin_rearm_player', target=target, admin=nick)
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
else:
|
else:
|
||||||
# Rearm everyone
|
# Rearm the admin themselves
|
||||||
for player_data in self.db.players.values():
|
player = self.db.get_player(nick)
|
||||||
player_data['gun_confiscated'] = False
|
player['gun_confiscated'] = False
|
||||||
player_data['ammo'] = player_data.get('max_ammo', 6)
|
|
||||||
player_data['chargers'] = 2
|
# Update magazines based on admin's level
|
||||||
message = self.messages.get('admin_rearm_all', admin=nick)
|
self.levels.update_player_magazines(player)
|
||||||
|
player['current_ammo'] = player.get('bullets_per_magazine', 6)
|
||||||
|
|
||||||
|
message = self.messages.get('admin_rearm_self', admin=nick)
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
|
|
||||||
self.db.save_database()
|
self.db.save_database()
|
||||||
|
|
||||||
async def handle_disarm(self, nick, channel, args):
|
async def handle_disarm(self, nick, channel, args):
|
||||||
"""Handle !disarm command (admin only)"""
|
"""Handle !disarm command (admin only)"""
|
||||||
if not args:
|
def disarm_player(player):
|
||||||
message = self.messages.get('usage_disarm')
|
player['gun_confiscated'] = True
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
target = args[0].lower()
|
self._handle_single_target_admin_command(
|
||||||
player = self.db.get_player(target)
|
args, 'usage_disarm', disarm_player, 'admin_disarm', nick, channel
|
||||||
player['gun_confiscated'] = True
|
)
|
||||||
|
|
||||||
message = self.messages.get('admin_disarm', target=target, admin=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
async def handle_ignore(self, nick, channel, args):
|
async def handle_ignore(self, nick, channel, args):
|
||||||
"""Handle !ignore command (admin only)"""
|
"""Handle !ignore command (admin only)"""
|
||||||
if not args:
|
def ignore_player(player):
|
||||||
message = self.messages.get('usage_ignore')
|
player['ignored'] = True
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
target = args[0].lower()
|
self._handle_single_target_admin_command(
|
||||||
player = self.db.get_player(target)
|
args, 'usage_ignore', ignore_player, 'admin_ignore', nick, channel
|
||||||
player['ignored'] = True
|
)
|
||||||
|
|
||||||
message = self.messages.get('admin_ignore', target=target, admin=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
async def handle_unignore(self, nick, channel, args):
|
async def handle_unignore(self, nick, channel, args):
|
||||||
"""Handle !unignore command (admin only)"""
|
"""Handle !unignore command (admin only)"""
|
||||||
if not args:
|
def unignore_player(player):
|
||||||
message = self.messages.get('usage_unignore')
|
player['ignored'] = False
|
||||||
self.send_message(channel, message)
|
|
||||||
return
|
|
||||||
|
|
||||||
target = args[0].lower()
|
self._handle_single_target_admin_command(
|
||||||
player = self.db.get_player(target)
|
args, 'usage_unignore', unignore_player, 'admin_unignore', nick, channel
|
||||||
player['ignored'] = False
|
)
|
||||||
|
|
||||||
message = self.messages.get('admin_unignore', target=target, admin=nick)
|
|
||||||
self.send_message(channel, message)
|
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
async def handle_ducklaunch(self, nick, channel, args):
|
async def handle_ducklaunch(self, nick, channel, args):
|
||||||
"""Handle !ducklaunch command (admin only)"""
|
"""Handle !ducklaunch command (admin only)"""
|
||||||
@@ -428,27 +441,23 @@ class DuckHuntBot:
|
|||||||
if channel not in self.game.ducks:
|
if channel not in self.game.ducks:
|
||||||
self.game.ducks[channel] = []
|
self.game.ducks[channel] = []
|
||||||
self.game.ducks[channel].append({"spawn_time": time.time()})
|
self.game.ducks[channel].append({"spawn_time": time.time()})
|
||||||
admin_message = self.messages.get('admin_ducklaunch', admin=nick)
|
|
||||||
duck_message = self.messages.get('duck_spawn')
|
duck_message = self.messages.get('duck_spawn')
|
||||||
|
|
||||||
self.send_message(channel, admin_message)
|
# Only send the duck spawn message, no admin notification
|
||||||
self.send_message(channel, duck_message)
|
self.send_message(channel, duck_message)
|
||||||
|
|
||||||
async def handle_reloadshop(self, nick, channel, args):
|
|
||||||
"""Handle !reloadshop admin command"""
|
|
||||||
old_count = len(self.shop.get_items())
|
|
||||||
new_count = self.shop.reload_items()
|
|
||||||
|
|
||||||
message = f"[ADMIN] Shop reloaded by {nick} - {new_count} items loaded"
|
|
||||||
self.send_message(channel, message)
|
|
||||||
self.logger.info(f"Shop reloaded by admin {nick}: {old_count} -> {new_count} items")
|
|
||||||
|
|
||||||
|
|
||||||
async def message_loop(self):
|
async def message_loop(self):
|
||||||
"""Main message processing loop"""
|
"""Main message processing loop with responsive shutdown"""
|
||||||
try:
|
try:
|
||||||
while not self.shutdown_requested and self.reader:
|
while not self.shutdown_requested and self.reader:
|
||||||
line = await self.reader.readline()
|
try:
|
||||||
|
# Use a timeout on readline to make it more responsive to shutdown
|
||||||
|
line = await asyncio.wait_for(self.reader.readline(), timeout=1.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Check shutdown flag and continue
|
||||||
|
continue
|
||||||
|
|
||||||
if not line:
|
if not line:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -470,7 +479,7 @@ class DuckHuntBot:
|
|||||||
self.logger.info("Message loop ended")
|
self.logger.info("Message loop ended")
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
"""Main bot loop with improved shutdown handling"""
|
"""Main bot loop with fast shutdown handling"""
|
||||||
self.setup_signal_handlers()
|
self.setup_signal_handlers()
|
||||||
|
|
||||||
game_task = None
|
game_task = None
|
||||||
@@ -486,79 +495,69 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.")
|
self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.")
|
||||||
|
|
||||||
# Wait for shutdown signal or task completion
|
# Wait for shutdown signal or task completion with frequent checks
|
||||||
done, pending = await asyncio.wait(
|
while not self.shutdown_requested:
|
||||||
[game_task, message_task],
|
done, pending = await asyncio.wait(
|
||||||
return_when=asyncio.FIRST_COMPLETED
|
[game_task, message_task],
|
||||||
)
|
timeout=0.1, # Check every 100ms for shutdown
|
||||||
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
|
||||||
|
# If any task completed, break out
|
||||||
|
if done:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.logger.info("🔄 Shutdown initiated, cleaning up...")
|
||||||
|
|
||||||
# Cancel remaining tasks
|
|
||||||
for task in pending:
|
|
||||||
if not task.done():
|
|
||||||
task.cancel()
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
self.logger.debug(f"Task cancelled: {task}")
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
self.logger.debug(f"Task timed out: {task}")
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
self.logger.info("🛑 Main loop cancelled")
|
self.logger.info("🛑 Main loop cancelled")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"❌ Bot error: {e}")
|
self.logger.error(f"❌ Bot error: {e}")
|
||||||
finally:
|
finally:
|
||||||
self.logger.info("🔄 Final cleanup...")
|
# Fast cleanup - cancel tasks immediately with short timeout
|
||||||
|
tasks_to_cancel = [task for task in [game_task, message_task] if task and not task.done()]
|
||||||
|
for task in tasks_to_cancel:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
# Ensure tasks are cancelled
|
# Wait briefly for tasks to cancel
|
||||||
for task in [game_task, message_task]:
|
if tasks_to_cancel:
|
||||||
if task and not task.done():
|
try:
|
||||||
task.cancel()
|
await asyncio.wait_for(
|
||||||
try:
|
asyncio.gather(*tasks_to_cancel, return_exceptions=True),
|
||||||
await task
|
timeout=1.0
|
||||||
except asyncio.CancelledError:
|
)
|
||||||
pass
|
except asyncio.TimeoutError:
|
||||||
|
self.logger.warning("⚠️ Task cancellation timed out")
|
||||||
|
|
||||||
# Final database save
|
# Quick database save
|
||||||
try:
|
try:
|
||||||
self.db.save_database()
|
self.db.save_database()
|
||||||
self.logger.info("💾 Database saved")
|
self.logger.info("💾 Database saved")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"❌ Error saving database: {e}")
|
self.logger.error(f"❌ Error saving database: {e}")
|
||||||
|
|
||||||
# Close IRC connection
|
# Fast connection close
|
||||||
await self._close_connection()
|
await self._close_connection()
|
||||||
|
|
||||||
self.logger.info("✅ Bot shutdown complete")
|
self.logger.info("✅ Bot shutdown complete")
|
||||||
|
|
||||||
async def _graceful_shutdown(self):
|
|
||||||
"""Perform graceful shutdown steps"""
|
|
||||||
try:
|
|
||||||
# Send quit message to IRC
|
|
||||||
if self.writer and not self.writer.is_closing():
|
|
||||||
self.logger.info("📤 Sending QUIT message to IRC...")
|
|
||||||
quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down')
|
|
||||||
self.send_raw(f"QUIT :{quit_message}")
|
|
||||||
|
|
||||||
# Give IRC server time to process quit
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
|
|
||||||
# Save database
|
|
||||||
self.logger.info("💾 Saving database...")
|
|
||||||
self.db.save_database()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"❌ Error during graceful shutdown: {e}")
|
|
||||||
|
|
||||||
async def _close_connection(self):
|
async def _close_connection(self):
|
||||||
"""Close IRC connection safely"""
|
"""Close IRC connection quickly"""
|
||||||
if self.writer:
|
if self.writer:
|
||||||
try:
|
try:
|
||||||
if not self.writer.is_closing():
|
if not self.writer.is_closing():
|
||||||
|
# Send quit message quickly without waiting
|
||||||
|
try:
|
||||||
|
quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down')
|
||||||
|
self.send_raw(f"QUIT :{quit_message}")
|
||||||
|
await asyncio.sleep(0.1) # Very brief wait
|
||||||
|
except:
|
||||||
|
pass # Don't block on quit message
|
||||||
|
|
||||||
self.writer.close()
|
self.writer.close()
|
||||||
await asyncio.wait_for(self.writer.wait_closed(), timeout=3.0)
|
await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0)
|
||||||
self.logger.info("🔌 IRC connection closed")
|
self.logger.info("🔌 IRC connection closed")
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
self.logger.warning("⚠️ Connection close timed out")
|
self.logger.warning("⚠️ Connection close timed out - forcing close")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"❌ Error closing connection: {e}")
|
self.logger.error(f"❌ Error closing connection: {e}")
|
||||||
201
src/game.py
201
src/game.py
@@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Simplified Game mechanics for DuckHunt Bot
|
Game mechanics for DuckHunt Bot
|
||||||
Basic duck spawning and timeout only
|
Handles duck spawning, shooting, befriending, and other game actions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -10,7 +10,7 @@ import logging
|
|||||||
|
|
||||||
|
|
||||||
class DuckGame:
|
class DuckGame:
|
||||||
"""Simplified game mechanics - just duck spawning"""
|
"""Game mechanics for DuckHunt - shooting, befriending, reloading"""
|
||||||
|
|
||||||
def __init__(self, bot, db):
|
def __init__(self, bot, db):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
@@ -31,15 +31,17 @@ class DuckGame:
|
|||||||
self.logger.info("Game loops cancelled")
|
self.logger.info("Game loops cancelled")
|
||||||
|
|
||||||
async def duck_spawn_loop(self):
|
async def duck_spawn_loop(self):
|
||||||
"""Simple duck spawning loop"""
|
"""Duck spawning loop with responsive shutdown"""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# Wait random time between spawns
|
# Wait random time between spawns, but in small chunks for responsiveness
|
||||||
min_wait = self.bot.get_config('duck_spawn_min', 300) # 5 minutes
|
min_wait = self.bot.get_config('duck_spawn_min', 300) # 5 minutes
|
||||||
max_wait = self.bot.get_config('duck_spawn_max', 900) # 15 minutes
|
max_wait = self.bot.get_config('duck_spawn_max', 900) # 15 minutes
|
||||||
wait_time = random.randint(min_wait, max_wait)
|
wait_time = random.randint(min_wait, max_wait)
|
||||||
|
|
||||||
await asyncio.sleep(wait_time)
|
# Sleep in 1-second intervals to allow for quick cancellation
|
||||||
|
for _ in range(wait_time):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# Spawn duck in random channel
|
# Spawn duck in random channel
|
||||||
channels = list(self.bot.channels_joined)
|
channels = list(self.bot.channels_joined)
|
||||||
@@ -51,10 +53,11 @@ class DuckGame:
|
|||||||
self.logger.info("Duck spawning loop cancelled")
|
self.logger.info("Duck spawning loop cancelled")
|
||||||
|
|
||||||
async def duck_timeout_loop(self):
|
async def duck_timeout_loop(self):
|
||||||
"""Simple duck timeout loop"""
|
"""Duck timeout loop with responsive shutdown"""
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(10) # Check every 10 seconds
|
# Check every 2 seconds instead of 10 for more responsiveness
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
channels_to_clear = []
|
channels_to_clear = []
|
||||||
@@ -102,4 +105,184 @@ class DuckGame:
|
|||||||
message = self.bot.messages.get('duck_spawn')
|
message = self.bot.messages.get('duck_spawn')
|
||||||
self.bot.send_message(channel, message)
|
self.bot.send_message(channel, message)
|
||||||
|
|
||||||
self.logger.info(f"Duck spawned in {channel}")
|
self.logger.info(f"Duck spawned in {channel}")
|
||||||
|
|
||||||
|
def shoot_duck(self, nick, channel, player):
|
||||||
|
"""Handle shooting at a duck"""
|
||||||
|
# Check if gun is confiscated
|
||||||
|
if player.get('gun_confiscated', False):
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'bang_not_armed',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check ammo
|
||||||
|
if player.get('current_ammo', 0) <= 0:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'bang_no_ammo',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for duck
|
||||||
|
if channel not in self.ducks or not self.ducks[channel]:
|
||||||
|
# Wild shot - gun confiscated
|
||||||
|
player['current_ammo'] = player.get('current_ammo', 1) - 1
|
||||||
|
player['gun_confiscated'] = True
|
||||||
|
self.db.save_database()
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'bang_no_duck',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shoot at duck
|
||||||
|
player['current_ammo'] = player.get('current_ammo', 1) - 1
|
||||||
|
|
||||||
|
# Calculate hit chance using level-modified accuracy
|
||||||
|
modified_accuracy = self.bot.levels.get_modified_accuracy(player)
|
||||||
|
hit_chance = modified_accuracy / 100.0
|
||||||
|
|
||||||
|
if random.random() < hit_chance:
|
||||||
|
# Hit! Remove the duck
|
||||||
|
duck = self.ducks[channel].pop(0)
|
||||||
|
xp_gained = 10
|
||||||
|
old_level = self.bot.levels.calculate_player_level(player)
|
||||||
|
player['xp'] = player.get('xp', 0) + xp_gained
|
||||||
|
player['ducks_shot'] = player.get('ducks_shot', 0) + 1
|
||||||
|
player['accuracy'] = min(player.get('accuracy', 65) + 1, 100)
|
||||||
|
|
||||||
|
# Check if player leveled up and update magazines if needed
|
||||||
|
new_level = self.bot.levels.calculate_player_level(player)
|
||||||
|
if new_level != old_level:
|
||||||
|
self.bot.levels.update_player_magazines(player)
|
||||||
|
|
||||||
|
self.db.save_database()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'hit': True,
|
||||||
|
'message_key': 'bang_hit',
|
||||||
|
'message_args': {
|
||||||
|
'nick': nick,
|
||||||
|
'xp_gained': xp_gained,
|
||||||
|
'ducks_shot': player['ducks_shot']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Miss! Duck stays in the channel
|
||||||
|
player['accuracy'] = max(player.get('accuracy', 65) - 2, 10)
|
||||||
|
self.db.save_database()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'hit': False,
|
||||||
|
'message_key': 'bang_miss',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
def befriend_duck(self, nick, channel, player):
|
||||||
|
"""Handle befriending a duck"""
|
||||||
|
# Check for duck
|
||||||
|
if channel not in self.ducks or not self.ducks[channel]:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'bef_no_duck',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check befriend success rate from config and level modifiers
|
||||||
|
base_rate = self.bot.get_config('befriend_success_rate', 75)
|
||||||
|
try:
|
||||||
|
if base_rate is not None:
|
||||||
|
base_rate = float(base_rate)
|
||||||
|
else:
|
||||||
|
base_rate = 75.0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
base_rate = 75.0
|
||||||
|
|
||||||
|
# Apply level-based modification to befriend rate
|
||||||
|
level_modified_rate = self.bot.levels.get_modified_befriend_rate(player, base_rate)
|
||||||
|
success_rate = level_modified_rate / 100.0
|
||||||
|
|
||||||
|
if random.random() < success_rate:
|
||||||
|
# Success - befriend the duck
|
||||||
|
duck = self.ducks[channel].pop(0)
|
||||||
|
|
||||||
|
# Lower XP gain than shooting (5 instead of 10)
|
||||||
|
xp_gained = 5
|
||||||
|
old_level = self.bot.levels.calculate_player_level(player)
|
||||||
|
player['xp'] = player.get('xp', 0) + xp_gained
|
||||||
|
player['ducks_befriended'] = player.get('ducks_befriended', 0) + 1
|
||||||
|
|
||||||
|
# Check if player leveled up and update magazines if needed
|
||||||
|
new_level = self.bot.levels.calculate_player_level(player)
|
||||||
|
if new_level != old_level:
|
||||||
|
self.bot.levels.update_player_magazines(player)
|
||||||
|
|
||||||
|
self.db.save_database()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'befriended': True,
|
||||||
|
'message_key': 'bef_success',
|
||||||
|
'message_args': {
|
||||||
|
'nick': nick,
|
||||||
|
'xp_gained': xp_gained,
|
||||||
|
'ducks_befriended': player['ducks_befriended']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Failure - duck flies away, remove from channel
|
||||||
|
duck = self.ducks[channel].pop(0)
|
||||||
|
|
||||||
|
self.db.save_database()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'befriended': False,
|
||||||
|
'message_key': 'bef_failed',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
def reload_gun(self, nick, channel, player):
|
||||||
|
"""Handle reloading a gun (switching to a new magazine)"""
|
||||||
|
if player.get('gun_confiscated', False):
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'reload_not_armed',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
current_ammo = player.get('current_ammo', 0)
|
||||||
|
bullets_per_mag = player.get('bullets_per_magazine', 6)
|
||||||
|
|
||||||
|
# Check if current magazine is already full
|
||||||
|
if current_ammo >= bullets_per_mag:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'reload_already_loaded',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if they have spare magazines
|
||||||
|
total_magazines = player.get('magazines', 1)
|
||||||
|
if total_magazines <= 1: # Only the current magazine
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'message_key': 'reload_no_chargers',
|
||||||
|
'message_args': {'nick': nick}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reload: discard current magazine and load a new full one
|
||||||
|
player['current_ammo'] = bullets_per_mag
|
||||||
|
player['magazines'] = total_magazines - 1
|
||||||
|
|
||||||
|
self.db.save_database()
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'message_key': 'reload_success',
|
||||||
|
'message_args': {
|
||||||
|
'nick': nick,
|
||||||
|
'ammo': player['current_ammo'],
|
||||||
|
'max_ammo': bullets_per_mag,
|
||||||
|
'chargers': player['magazines'] - 1 # Spare magazines (excluding current)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,14 +38,14 @@ class LevelManager:
|
|||||||
"""Default fallback level system"""
|
"""Default fallback level system"""
|
||||||
return {
|
return {
|
||||||
"level_calculation": {
|
"level_calculation": {
|
||||||
"method": "total_ducks",
|
"method": "xp",
|
||||||
"description": "Level based on total ducks interacted with"
|
"description": "Level based on XP earned"
|
||||||
},
|
},
|
||||||
"levels": {
|
"levels": {
|
||||||
"1": {
|
"1": {
|
||||||
"name": "Duck Novice",
|
"name": "Duck Novice",
|
||||||
"min_ducks": 0,
|
"min_xp": 0,
|
||||||
"max_ducks": 9,
|
"max_xp": 49,
|
||||||
"befriend_success_rate": 85,
|
"befriend_success_rate": 85,
|
||||||
"accuracy_modifier": 5,
|
"accuracy_modifier": 5,
|
||||||
"duck_spawn_speed_modifier": 1.0,
|
"duck_spawn_speed_modifier": 1.0,
|
||||||
@@ -53,8 +53,8 @@ class LevelManager:
|
|||||||
},
|
},
|
||||||
"2": {
|
"2": {
|
||||||
"name": "Duck Hunter",
|
"name": "Duck Hunter",
|
||||||
"min_ducks": 10,
|
"min_xp": 50,
|
||||||
"max_ducks": 99,
|
"max_xp": 299,
|
||||||
"befriend_success_rate": 75,
|
"befriend_success_rate": 75,
|
||||||
"accuracy_modifier": 0,
|
"accuracy_modifier": 0,
|
||||||
"duck_spawn_speed_modifier": 0.8,
|
"duck_spawn_speed_modifier": 0.8,
|
||||||
@@ -65,20 +65,24 @@ class LevelManager:
|
|||||||
|
|
||||||
def calculate_player_level(self, player: Dict[str, Any]) -> int:
|
def calculate_player_level(self, player: Dict[str, Any]) -> int:
|
||||||
"""Calculate a player's current level based on their stats"""
|
"""Calculate a player's current level based on their stats"""
|
||||||
method = self.levels_data.get('level_calculation', {}).get('method', 'total_ducks')
|
method = self.levels_data.get('level_calculation', {}).get('method', 'xp')
|
||||||
|
|
||||||
if method == 'total_ducks':
|
if method == 'xp':
|
||||||
|
player_xp = player.get('xp', 0)
|
||||||
|
elif method == 'total_ducks':
|
||||||
|
# Fallback to duck-based calculation if specified
|
||||||
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
|
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
|
||||||
elif method == 'xp':
|
player_xp = total_ducks # Use duck count as if it were XP
|
||||||
total_ducks = player.get('xp', 0) // 10 # 10 XP per "duck equivalent"
|
|
||||||
else:
|
else:
|
||||||
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
|
player_xp = player.get('xp', 0)
|
||||||
|
|
||||||
# Find the appropriate level
|
# Find the appropriate level
|
||||||
levels = self.levels_data.get('levels', {})
|
levels = self.levels_data.get('levels', {})
|
||||||
for level_num in sorted(levels.keys(), key=int, reverse=True):
|
for level_num in sorted(levels.keys(), key=int, reverse=True):
|
||||||
level_data = levels[level_num]
|
level_data = levels[level_num]
|
||||||
if total_ducks >= level_data.get('min_ducks', 0):
|
# Check for XP-based thresholds first, fallback to duck-based
|
||||||
|
min_threshold = level_data.get('min_xp', level_data.get('min_ducks', 0))
|
||||||
|
if player_xp >= min_threshold:
|
||||||
return int(level_num)
|
return int(level_num)
|
||||||
|
|
||||||
return 1 # Default to level 1
|
return 1 # Default to level 1
|
||||||
@@ -102,15 +106,23 @@ class LevelManager:
|
|||||||
"duck_spawn_speed_modifier": 1.0
|
"duck_spawn_speed_modifier": 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
total_ducks = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
|
method = self.levels_data.get('level_calculation', {}).get('method', 'xp')
|
||||||
|
if method == 'xp':
|
||||||
|
current_value = player.get('xp', 0)
|
||||||
|
value_type = "xp"
|
||||||
|
else:
|
||||||
|
current_value = player.get('ducks_shot', 0) + player.get('ducks_befriended', 0)
|
||||||
|
value_type = "ducks"
|
||||||
|
|
||||||
# Calculate progress to next level
|
# Calculate progress to next level
|
||||||
next_level_data = self.get_level_data(level + 1)
|
next_level_data = self.get_level_data(level + 1)
|
||||||
if next_level_data:
|
if next_level_data:
|
||||||
ducks_needed = next_level_data.get('min_ducks', 0) - total_ducks
|
threshold_key = f'min_{value_type}' if value_type == 'xp' else 'min_ducks'
|
||||||
|
next_threshold = next_level_data.get(threshold_key, 0)
|
||||||
|
needed_for_next = next_threshold - current_value
|
||||||
next_level_name = next_level_data.get('name', f"Level {level + 1}")
|
next_level_name = next_level_data.get('name', f"Level {level + 1}")
|
||||||
else:
|
else:
|
||||||
ducks_needed = 0
|
needed_for_next = 0
|
||||||
next_level_name = "Max Level"
|
next_level_name = "Max Level"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -120,9 +132,11 @@ class LevelManager:
|
|||||||
"befriend_success_rate": level_data.get('befriend_success_rate', 75),
|
"befriend_success_rate": level_data.get('befriend_success_rate', 75),
|
||||||
"accuracy_modifier": level_data.get('accuracy_modifier', 0),
|
"accuracy_modifier": level_data.get('accuracy_modifier', 0),
|
||||||
"duck_spawn_speed_modifier": level_data.get('duck_spawn_speed_modifier', 1.0),
|
"duck_spawn_speed_modifier": level_data.get('duck_spawn_speed_modifier', 1.0),
|
||||||
"total_ducks": total_ducks,
|
"current_xp": player.get('xp', 0),
|
||||||
"ducks_needed_for_next": max(0, ducks_needed),
|
"total_ducks": player.get('ducks_shot', 0) + player.get('ducks_befriended', 0),
|
||||||
"next_level_name": next_level_name
|
"needed_for_next": max(0, needed_for_next),
|
||||||
|
"next_level_name": next_level_name,
|
||||||
|
"value_type": value_type
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_modified_accuracy(self, player: Dict[str, Any]) -> int:
|
def get_modified_accuracy(self, player: Dict[str, Any]) -> int:
|
||||||
@@ -163,4 +177,44 @@ class LevelManager:
|
|||||||
self.load_levels()
|
self.load_levels()
|
||||||
new_count = len(self.levels_data.get('levels', {}))
|
new_count = len(self.levels_data.get('levels', {}))
|
||||||
self.logger.info(f"Levels reloaded: {old_count} -> {new_count} levels")
|
self.logger.info(f"Levels reloaded: {old_count} -> {new_count} levels")
|
||||||
return new_count
|
return new_count
|
||||||
|
|
||||||
|
def update_player_magazines(self, player: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Update player's magazine count based on their current level"""
|
||||||
|
level_info = self.get_player_level_info(player)
|
||||||
|
level_magazines = level_info.get('magazines', 3)
|
||||||
|
level_bullets_per_mag = level_info.get('bullets_per_magazine', 6)
|
||||||
|
|
||||||
|
# Get current magazine status
|
||||||
|
current_magazines = player.get('magazines', 3)
|
||||||
|
current_ammo = player.get('current_ammo', 6)
|
||||||
|
current_bullets_per_mag = player.get('bullets_per_magazine', 6)
|
||||||
|
|
||||||
|
# Calculate total bullets they currently have
|
||||||
|
total_current_bullets = current_ammo + (current_magazines - 1) * current_bullets_per_mag
|
||||||
|
|
||||||
|
# Update magazine system to level requirements
|
||||||
|
player['magazines'] = level_magazines
|
||||||
|
player['bullets_per_magazine'] = level_bullets_per_mag
|
||||||
|
|
||||||
|
# Redistribute bullets across new magazine system
|
||||||
|
max_total_bullets = level_magazines * level_bullets_per_mag
|
||||||
|
new_total_bullets = min(total_current_bullets, max_total_bullets)
|
||||||
|
|
||||||
|
# Calculate how to distribute bullets
|
||||||
|
if new_total_bullets <= 0:
|
||||||
|
player['current_ammo'] = 0
|
||||||
|
elif new_total_bullets <= level_bullets_per_mag:
|
||||||
|
# All bullets fit in current magazine
|
||||||
|
player['current_ammo'] = new_total_bullets
|
||||||
|
else:
|
||||||
|
# Fill current magazine, save rest for other magazines
|
||||||
|
player['current_ammo'] = level_bullets_per_mag
|
||||||
|
|
||||||
|
return {
|
||||||
|
'old_magazines': current_magazines,
|
||||||
|
'new_magazines': level_magazines,
|
||||||
|
'old_total_bullets': total_current_bullets,
|
||||||
|
'new_total_bullets': new_total_bullets,
|
||||||
|
'current_ammo': player['current_ammo']
|
||||||
|
}
|
||||||
323
src/shop.py
323
src/shop.py
@@ -1,10 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Shop system for DuckHunt Bot
|
Shop system for DuckHunt Bot
|
||||||
Handles loading items, purchasing, and item effects
|
Handles loading items, purchasing, and item effects including player-vs-player actions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
@@ -62,15 +63,33 @@ class ShopManager:
|
|||||||
return False
|
return False
|
||||||
return player_xp >= item['price']
|
return player_xp >= item['price']
|
||||||
|
|
||||||
def purchase_item(self, player: Dict[str, Any], item_id: int) -> Dict[str, Any]:
|
def purchase_item(self, player: Dict[str, Any], item_id: int, target_player: Optional[Dict[str, Any]] = None, store_in_inventory: bool = False) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Purchase an item and apply its effects to the player
|
Purchase an item and either store in inventory or apply immediately
|
||||||
Returns a result dictionary with success status and details
|
Returns a result dictionary with success status and details
|
||||||
"""
|
"""
|
||||||
item = self.get_item(item_id)
|
item = self.get_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
return {"success": False, "error": "invalid_id", "message": "Invalid item ID"}
|
return {"success": False, "error": "invalid_id", "message": "Invalid item ID"}
|
||||||
|
|
||||||
|
# If storing in inventory and item requires a target, that's invalid
|
||||||
|
if store_in_inventory and item.get('target_required', False):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "invalid_storage",
|
||||||
|
"message": f"{item['name']} cannot be stored - it targets other players",
|
||||||
|
"item_name": item['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if item requires a target (only when not storing)
|
||||||
|
if not store_in_inventory and item.get('target_required', False) and not target_player:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "target_required",
|
||||||
|
"message": f"{item['name']} requires a target player",
|
||||||
|
"item_name": item['name']
|
||||||
|
}
|
||||||
|
|
||||||
player_xp = player.get('xp', 0)
|
player_xp = player.get('xp', 0)
|
||||||
if player_xp < item['price']:
|
if player_xp < item['price']:
|
||||||
return {
|
return {
|
||||||
@@ -85,16 +104,47 @@ class ShopManager:
|
|||||||
# Deduct XP
|
# Deduct XP
|
||||||
player['xp'] = player_xp - item['price']
|
player['xp'] = player_xp - item['price']
|
||||||
|
|
||||||
# Apply item effect
|
if store_in_inventory:
|
||||||
effect_result = self._apply_item_effect(player, item)
|
# Add to inventory
|
||||||
|
inventory = player.get('inventory', {})
|
||||||
return {
|
item_id_str = str(item_id)
|
||||||
"success": True,
|
current_count = inventory.get(item_id_str, 0)
|
||||||
"item_name": item['name'],
|
inventory[item_id_str] = current_count + 1
|
||||||
"price": item['price'],
|
player['inventory'] = inventory
|
||||||
"remaining_xp": player['xp'],
|
|
||||||
"effect": effect_result
|
return {
|
||||||
}
|
"success": True,
|
||||||
|
"item_name": item['name'],
|
||||||
|
"price": item['price'],
|
||||||
|
"remaining_xp": player['xp'],
|
||||||
|
"stored_in_inventory": True,
|
||||||
|
"inventory_count": inventory[item_id_str]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Apply effect immediately
|
||||||
|
if item.get('target_required', False) and target_player:
|
||||||
|
effect_result = self._apply_item_effect(target_player, item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"item_name": item['name'],
|
||||||
|
"price": item['price'],
|
||||||
|
"remaining_xp": player['xp'],
|
||||||
|
"effect": effect_result,
|
||||||
|
"target_affected": True
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Apply effect to purchaser
|
||||||
|
effect_result = self._apply_item_effect(player, item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"item_name": item['name'],
|
||||||
|
"price": item['price'],
|
||||||
|
"remaining_xp": player['xp'],
|
||||||
|
"effect": effect_result,
|
||||||
|
"target_affected": False
|
||||||
|
}
|
||||||
|
|
||||||
def _apply_item_effect(self, player: Dict[str, Any], item: Dict[str, Any]) -> Dict[str, Any]:
|
def _apply_item_effect(self, player: Dict[str, Any], item: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Apply the effect of an item to a player"""
|
"""Apply the effect of an item to a player"""
|
||||||
@@ -102,16 +152,28 @@ class ShopManager:
|
|||||||
amount = item.get('amount', 0)
|
amount = item.get('amount', 0)
|
||||||
|
|
||||||
if item_type == 'ammo':
|
if item_type == 'ammo':
|
||||||
# Add ammo up to max capacity
|
# Add bullets to current magazine
|
||||||
current_ammo = player.get('ammo', 0)
|
current_ammo = player.get('current_ammo', 0)
|
||||||
max_ammo = player.get('max_ammo', 6)
|
bullets_per_mag = player.get('bullets_per_magazine', 6)
|
||||||
new_ammo = min(current_ammo + amount, max_ammo)
|
new_ammo = min(current_ammo + amount, bullets_per_mag)
|
||||||
player['ammo'] = new_ammo
|
added_bullets = new_ammo - current_ammo
|
||||||
|
player['current_ammo'] = new_ammo
|
||||||
return {
|
return {
|
||||||
"type": "ammo",
|
"type": "ammo",
|
||||||
"added": new_ammo - current_ammo,
|
"added": added_bullets,
|
||||||
"new_total": new_ammo,
|
"new_total": new_ammo,
|
||||||
"max": max_ammo
|
"max": bullets_per_mag
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'magazine':
|
||||||
|
# Add magazines to player's inventory
|
||||||
|
current_magazines = player.get('magazines', 1)
|
||||||
|
new_magazines = current_magazines + amount
|
||||||
|
player['magazines'] = new_magazines
|
||||||
|
return {
|
||||||
|
"type": "magazine",
|
||||||
|
"added": amount,
|
||||||
|
"new_total": new_magazines
|
||||||
}
|
}
|
||||||
|
|
||||||
elif item_type == 'accuracy':
|
elif item_type == 'accuracy':
|
||||||
@@ -136,14 +198,233 @@ class ShopManager:
|
|||||||
"new_total": new_luck
|
"new_total": new_luck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elif item_type == 'jam_resistance':
|
||||||
|
# Reduce gun jamming chance (lower is better)
|
||||||
|
current_jam = player.get('jam_chance', 5) # Default 5% jam chance
|
||||||
|
new_jam = max(current_jam - amount, 0) # Can't go below 0%
|
||||||
|
player['jam_chance'] = new_jam
|
||||||
|
return {
|
||||||
|
"type": "jam_resistance",
|
||||||
|
"reduced": current_jam - new_jam,
|
||||||
|
"new_total": new_jam
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'max_ammo':
|
||||||
|
# Increase maximum ammo capacity
|
||||||
|
current_max = player.get('max_ammo', 6)
|
||||||
|
new_max = current_max + amount
|
||||||
|
player['max_ammo'] = new_max
|
||||||
|
return {
|
||||||
|
"type": "max_ammo",
|
||||||
|
"added": amount,
|
||||||
|
"new_total": new_max
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'chargers':
|
||||||
|
# Add reload chargers
|
||||||
|
current_chargers = player.get('chargers', 2)
|
||||||
|
new_chargers = current_chargers + amount
|
||||||
|
player['chargers'] = new_chargers
|
||||||
|
return {
|
||||||
|
"type": "chargers",
|
||||||
|
"added": amount,
|
||||||
|
"new_total": new_chargers
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'duck_attraction':
|
||||||
|
# Increase chance of ducks appearing when this player is online
|
||||||
|
current_attraction = player.get('duck_attraction', 0)
|
||||||
|
new_attraction = current_attraction + amount
|
||||||
|
player['duck_attraction'] = new_attraction
|
||||||
|
return {
|
||||||
|
"type": "duck_attraction",
|
||||||
|
"added": amount,
|
||||||
|
"new_total": new_attraction
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'critical_hit':
|
||||||
|
# Chance for critical hits (double XP)
|
||||||
|
current_crit = player.get('critical_chance', 0)
|
||||||
|
new_crit = min(current_crit + amount, 25) # Max 25% crit chance
|
||||||
|
player['critical_chance'] = new_crit
|
||||||
|
return {
|
||||||
|
"type": "critical_hit",
|
||||||
|
"added": new_crit - current_crit,
|
||||||
|
"new_total": new_crit
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'sabotage_jam':
|
||||||
|
# Increase target's gun jamming chance temporarily
|
||||||
|
current_jam = player.get('jam_chance', 5)
|
||||||
|
new_jam = min(current_jam + amount, 50) # Max 50% jam chance
|
||||||
|
player['jam_chance'] = new_jam
|
||||||
|
|
||||||
|
# Add temporary effect tracking
|
||||||
|
if 'temporary_effects' not in player:
|
||||||
|
player['temporary_effects'] = []
|
||||||
|
|
||||||
|
effect = {
|
||||||
|
'type': 'jam_increase',
|
||||||
|
'amount': amount,
|
||||||
|
'expires_at': time.time() + item.get('duration', 5) * 60 # duration in minutes
|
||||||
|
}
|
||||||
|
player['temporary_effects'].append(effect)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "sabotage_jam",
|
||||||
|
"added": new_jam - current_jam,
|
||||||
|
"new_total": new_jam,
|
||||||
|
"duration": item.get('duration', 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'sabotage_accuracy':
|
||||||
|
# Reduce target's accuracy temporarily
|
||||||
|
current_acc = player.get('accuracy', 65)
|
||||||
|
new_acc = max(current_acc + amount, 10) # Min 10% accuracy (amount is negative)
|
||||||
|
player['accuracy'] = new_acc
|
||||||
|
|
||||||
|
# Add temporary effect tracking
|
||||||
|
if 'temporary_effects' not in player:
|
||||||
|
player['temporary_effects'] = []
|
||||||
|
|
||||||
|
effect = {
|
||||||
|
'type': 'accuracy_reduction',
|
||||||
|
'amount': amount,
|
||||||
|
'expires_at': time.time() + item.get('duration', 3) * 60
|
||||||
|
}
|
||||||
|
player['temporary_effects'].append(effect)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "sabotage_accuracy",
|
||||||
|
"reduced": current_acc - new_acc,
|
||||||
|
"new_total": new_acc,
|
||||||
|
"duration": item.get('duration', 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
elif item_type == 'steal_ammo':
|
||||||
|
# Steal ammo from target player
|
||||||
|
current_ammo = player.get('ammo', 0)
|
||||||
|
stolen = min(amount, current_ammo)
|
||||||
|
player['ammo'] = max(current_ammo - stolen, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "steal_ammo",
|
||||||
|
"stolen": stolen,
|
||||||
|
"remaining": player['ammo']
|
||||||
|
}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Unknown item type: {item_type}")
|
self.logger.warning(f"Unknown item type: {item_type}")
|
||||||
return {"type": "unknown", "message": f"Unknown effect type: {item_type}"}
|
return {"type": "unknown", "message": f"Unknown effect type: {item_type}"}
|
||||||
|
|
||||||
|
def use_inventory_item(self, player: Dict[str, Any], item_id: int, target_player: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Use an item from player's inventory
|
||||||
|
Returns a result dictionary with success status and details
|
||||||
|
"""
|
||||||
|
item = self.get_item(item_id)
|
||||||
|
if not item:
|
||||||
|
return {"success": False, "error": "invalid_id", "message": "Invalid item ID"}
|
||||||
|
|
||||||
|
inventory = player.get('inventory', {})
|
||||||
|
item_id_str = str(item_id)
|
||||||
|
|
||||||
|
if item_id_str not in inventory or inventory[item_id_str] <= 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "not_in_inventory",
|
||||||
|
"message": f"You don't have any {item['name']} in your inventory",
|
||||||
|
"item_name": item['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if item requires a target
|
||||||
|
if item.get('target_required', False) and not target_player:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "target_required",
|
||||||
|
"message": f"{item['name']} requires a target player",
|
||||||
|
"item_name": item['name']
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove item from inventory
|
||||||
|
inventory[item_id_str] -= 1
|
||||||
|
if inventory[item_id_str] <= 0:
|
||||||
|
del inventory[item_id_str]
|
||||||
|
player['inventory'] = inventory
|
||||||
|
|
||||||
|
# Apply effect
|
||||||
|
if item.get('target_required', False) and target_player:
|
||||||
|
effect_result = self._apply_item_effect(target_player, item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"item_name": item['name'],
|
||||||
|
"effect": effect_result,
|
||||||
|
"target_affected": True,
|
||||||
|
"remaining_in_inventory": inventory.get(item_id_str, 0)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Apply effect to user
|
||||||
|
effect_result = self._apply_item_effect(player, item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"item_name": item['name'],
|
||||||
|
"effect": effect_result,
|
||||||
|
"target_affected": False,
|
||||||
|
"remaining_in_inventory": inventory.get(item_id_str, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_inventory_display(self, player: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get formatted inventory display for a player
|
||||||
|
Returns dict with inventory info
|
||||||
|
"""
|
||||||
|
inventory = player.get('inventory', {})
|
||||||
|
if not inventory:
|
||||||
|
return {
|
||||||
|
"empty": True,
|
||||||
|
"message": "Your inventory is empty"
|
||||||
|
}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for item_id_str, quantity in inventory.items():
|
||||||
|
item_id = int(item_id_str)
|
||||||
|
item = self.get_item(item_id)
|
||||||
|
if item:
|
||||||
|
items.append({
|
||||||
|
"id": item_id,
|
||||||
|
"name": item['name'],
|
||||||
|
"quantity": quantity,
|
||||||
|
"description": item.get('description', 'No description')
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"empty": False,
|
||||||
|
"items": items,
|
||||||
|
"total_items": len(items)
|
||||||
|
}
|
||||||
|
|
||||||
def reload_items(self) -> int:
|
def reload_items(self) -> int:
|
||||||
"""Reload items from file and return count"""
|
"""Reload items from file and return count"""
|
||||||
old_count = len(self.items)
|
old_count = len(self.items)
|
||||||
self.load_items()
|
self.load_items()
|
||||||
new_count = len(self.items)
|
new_count = len(self.items)
|
||||||
self.logger.info(f"Shop reloaded: {old_count} -> {new_count} items")
|
self.logger.info(f"Shop reloaded: {old_count} -> {new_count} items")
|
||||||
return new_count
|
return new_count
|
||||||
|
|
||||||
|
def get_shop_display(self, player, message_manager):
|
||||||
|
"""Get formatted shop display"""
|
||||||
|
items = []
|
||||||
|
for item_id, item in self.get_items().items():
|
||||||
|
item_text = message_manager.get('shop_item_format',
|
||||||
|
id=item_id,
|
||||||
|
name=item['name'],
|
||||||
|
price=item['price'])
|
||||||
|
items.append(item_text)
|
||||||
|
|
||||||
|
shop_text = message_manager.get('shop_display',
|
||||||
|
items=" | ".join(items),
|
||||||
|
xp=player.get('xp', 0))
|
||||||
|
|
||||||
|
return shop_text
|
||||||
Reference in New Issue
Block a user