From ba7f082d5c495162ccfb0f5649e67e8bc638a2b6 Mon Sep 17 00:00:00 2001 From: ComputerTech312 Date: Fri, 19 Sep 2025 21:43:25 +0100 Subject: [PATCH] Implement competitive item snatching system - Add dropped items tracking with timestamps per channel - Items drop to ground (10% chance on duck kills) for any player to grab - Add 60-second timeout for unclaimed items - Background cleanup task removes expired items automatically - First-come-first-served basis for item collection - Eggdrop-style messaging for drops and successful snatches --- README.md | 0 __pycache__/duckhunt.cpython-312.pyc | Bin 0 -> 1756 bytes {duckhunt => backup}/simple_duckhunt.py | 0 duckhunt/config.json => config.json | 6 +- duckhunt/duckhunt.json => duckhunt.json | 97 +- duckhunt.log | 198 ++ duckhunt/duckhunt.py => duckhunt.py | 11 +- duckhunt/CONFIG_GUIDE.md | 272 -- duckhunt/README.md | 172 -- .../simple_duckhunt.cpython-312.pyc | Bin 141195 -> 0 bytes duckhunt/config_backup.json | 216 -- duckhunt/config_local.json | 205 -- duckhunt/config_new.json | 0 duckhunt/demo_sasl.py | 122 - duckhunt/duckhunt.db | Bin 36864 -> 0 bytes duckhunt/duckhunt.log | 559 ---- duckhunt/simple_duckhunt.py.backup | 2690 ----------------- duckhunt/src/__pycache__/auth.cpython-312.pyc | Bin 6452 -> 0 bytes duckhunt/src/__pycache__/db.cpython-312.pyc | Bin 6838 -> 0 bytes .../__pycache__/duckhuntbot.cpython-312.pyc | Bin 14681 -> 0 bytes duckhunt/src/__pycache__/game.cpython-312.pyc | Bin 33969 -> 0 bytes .../src/__pycache__/items.cpython-312.pyc | Bin 2878 -> 0 bytes .../__pycache__/logging_utils.cpython-312.pyc | Bin 1724 -> 0 bytes .../src/__pycache__/utils.cpython-312.pyc | Bin 705 -> 0 bytes duckhunt/src/auth.py | 108 - duckhunt/src/db.py | 97 - duckhunt/src/duckhuntbot.py | 277 -- duckhunt/src/game.py | 566 ---- duckhunt/src/items.py | 124 - duckhunt/src/logging_utils.py | 28 - duckhunt/src/utils.py | 11 - duckhunt/test_bot.py | 167 - src/__pycache__/db.cpython-312.pyc | Bin 0 -> 9706 bytes src/__pycache__/duckhuntbot.cpython-312.pyc | Bin 0 -> 62728 bytes src/__pycache__/game.cpython-312.pyc | Bin 0 -> 15826 bytes src/__pycache__/logging_utils.cpython-312.pyc | Bin 0 -> 3275 bytes .../__pycache__/sasl.cpython-312.pyc | Bin 11033 -> 11033 bytes src/__pycache__/utils.cpython-312.pyc | Bin 0 -> 3105 bytes src/db.py | 189 ++ src/duckhuntbot.py | 1195 ++++++++ src/game.py | 326 ++ src/logging_utils.py | 69 + {duckhunt/src => src}/sasl.py | 0 src/utils.py | 63 + 44 files changed, 2142 insertions(+), 5626 deletions(-) delete mode 100644 README.md create mode 100644 __pycache__/duckhunt.cpython-312.pyc rename {duckhunt => backup}/simple_duckhunt.py (100%) rename duckhunt/config.json => config.json (98%) rename duckhunt/duckhunt.json => duckhunt.json (91%) create mode 100644 duckhunt.log rename duckhunt/duckhunt.py => duckhunt.py (76%) delete mode 100644 duckhunt/CONFIG_GUIDE.md delete mode 100644 duckhunt/README.md delete mode 100644 duckhunt/__pycache__/simple_duckhunt.cpython-312.pyc delete mode 100644 duckhunt/config_backup.json delete mode 100644 duckhunt/config_local.json delete mode 100644 duckhunt/config_new.json delete mode 100644 duckhunt/demo_sasl.py delete mode 100644 duckhunt/duckhunt.db delete mode 100644 duckhunt/duckhunt.log delete mode 100644 duckhunt/simple_duckhunt.py.backup delete mode 100644 duckhunt/src/__pycache__/auth.cpython-312.pyc delete mode 100644 duckhunt/src/__pycache__/db.cpython-312.pyc delete mode 100644 duckhunt/src/__pycache__/duckhuntbot.cpython-312.pyc delete mode 100644 duckhunt/src/__pycache__/game.cpython-312.pyc delete mode 100644 duckhunt/src/__pycache__/items.cpython-312.pyc delete mode 100644 duckhunt/src/__pycache__/logging_utils.cpython-312.pyc delete mode 100644 duckhunt/src/__pycache__/utils.cpython-312.pyc delete mode 100644 duckhunt/src/auth.py delete mode 100644 duckhunt/src/db.py delete mode 100644 duckhunt/src/duckhuntbot.py delete mode 100644 duckhunt/src/game.py delete mode 100644 duckhunt/src/items.py delete mode 100644 duckhunt/src/logging_utils.py delete mode 100644 duckhunt/src/utils.py delete mode 100644 duckhunt/test_bot.py create mode 100644 src/__pycache__/db.cpython-312.pyc create mode 100644 src/__pycache__/duckhuntbot.cpython-312.pyc create mode 100644 src/__pycache__/game.cpython-312.pyc create mode 100644 src/__pycache__/logging_utils.cpython-312.pyc rename {duckhunt/src => src}/__pycache__/sasl.cpython-312.pyc (99%) create mode 100644 src/__pycache__/utils.cpython-312.pyc create mode 100644 src/db.py create mode 100644 src/duckhuntbot.py create mode 100644 src/game.py create mode 100644 src/logging_utils.py rename {duckhunt/src => src}/sasl.py (100%) create mode 100644 src/utils.py diff --git a/README.md b/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/__pycache__/duckhunt.cpython-312.pyc b/__pycache__/duckhunt.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0008df4906fd560e1f3a88943a1ce678b166f164 GIT binary patch literal 1756 zcmb7EO-vg{6n?Y2w%1;4$HHj>YTBhVX=sZnttc%*kqm_tRYg^E!m6|~-Zj`UyY6@` zFoL$J+6JUF80BE3C`zJ8jVdlV_Eaf}da5d+LaTP*)Sp9cE+9oBm%dqhF(~xVS!v#T zGv9pg&3kY5$6zo3IDWYI)8thV;7@M2L#``3+o)7PhXm+E7jh&)94RCOM~Vs2ky1i3 z#H_>TQq*KNc-ZEaP7LWtZ1`#He;^}AApNVPwyKM(LQQie;n7REz?70uQ1^TRi6$ec zdoP{^pt1l)(`ucNKuIVG9lqJqfp?0ji|-aqTOIjuP#v&rwNrgRnK9KN)21c$11n?N zfk*tz2~pMwwL(7D zAkzYYF(CL|xiBM+!8Bl4-4|A7}4}2)40+WoHjLrN*Gbtl)XDqWrVkFJHwMdybZrO|&1@2hIv8Q%TT2n?> z%F2zGx^#S$llUn2l~}&Sq$zxtl;?y07Yg6w^chs8W>565V5Ho+9#pG9{4d;!Zyj%` zj4h9?e0=Srh2h5{uI;)4@saehc-KKt7TyTymy9@LqvX*?yGh9&iBnr@{ujW!`C<$nXpv%~}oE0Isy zlgyhjv14p3J2ocs=^1J!rwmS##sX>@3*+xC=4Kwt<1~enZw}&MP5TyhkQFF@etUBN zl%*GQ#yNThP2Ot-9jB^D2>C}6h_oXE32%b334u-USG_=_%km}pa`;lXOv&@{E82B4r+V&nN(dug;H7(5kEVleE$zKjvMUYzQ HX>{~296frA literal 0 HcmV?d00001 diff --git a/duckhunt/simple_duckhunt.py b/backup/simple_duckhunt.py similarity index 100% rename from duckhunt/simple_duckhunt.py rename to backup/simple_duckhunt.py diff --git a/duckhunt/config.json b/config.json similarity index 98% rename from duckhunt/config.json rename to config.json index 4e97991..0c74b5f 100644 --- a/duckhunt/config.json +++ b/config.json @@ -1,11 +1,11 @@ { "server": "irc.rizon.net", "port": 6697, - "nick": "DuckHunt", - "channels": ["#computertech"], + "nick": "DickHunt", + "channels": ["#ct"], "ssl": true, "sasl": { - "enabled": true, + "enabled": false, "username": "duckhunt", "password": "duckhunt//789//" }, diff --git a/duckhunt/duckhunt.json b/duckhunt.json similarity index 91% rename from duckhunt/duckhunt.json rename to duckhunt.json index dd16754..b34f2ee 100644 --- a/duckhunt/duckhunt.json +++ b/duckhunt.json @@ -22,7 +22,7 @@ "caught": 12, "ammo": 5, "max_ammo": 10, - "chargers": 0, + "chargers": 2, "max_chargers": 2, "xp": 100, "accuracy": 65, @@ -31,22 +31,37 @@ "gun_level": 1, "luck": 0, "gun_type": "pistol", - "gun_confiscated": false, + "gun_confiscated": true, "jammed": false, "jammed_count": 1, - "total_ammo_used": 12, + "total_ammo_used": 18, "shot_at": 9, "reflex_shots": 6, "total_reflex_time": 47.87793278694153, "best_time": 2.6269078254699707, "karma": 1, - "wild_shots": 4, + "wild_shots": 10, "befriended": 6, "missed": 1, "inventory": { "15": 1 }, - "sand": 1 + "sand": 1, + "shots": 9, + "max_shots": 10, + "reload_time": 5.0, + "ducks_shot": 12, + "ducks_befriended": 6, + "accuracy_bonus": 0, + "xp_bonus": 0, + "charm_bonus": 0, + "exp": 100, + "money": 119, + "last_hunt": 0, + "last_reload": 0, + "level": 1, + "ignored_users": [], + "confiscated_count": 2 }, "colby_": { "xp": 0, @@ -1014,7 +1029,77 @@ "bread": 0, "duck_detector": 0, "mechanical": 0 + }, + "py-ctcp": { + "xp": 0, + "caught": 0, + "befriended": 0, + "missed": 0, + "ammo": 6, + "max_ammo": 6, + "chargers": 2, + "max_chargers": 2, + "accuracy": 65, + "reliability": 70, + "weapon": "pistol", + "gun_confiscated": false, + "explosive_ammo": false, + "settings": { + "output_mode": "PUBLIC", + "notices": false, + "private_messages": false + }, + "inventory": {}, + "golden_ducks": 0, + "karma": 0, + "deflection": 0, + "defense": 0, + "jammed": false, + "jammed_count": 0, + "deaths": 0, + "neutralized": 0, + "deflected": 0, + "best_time": 999.9, + "total_reflex_time": 0.0, + "reflex_shots": 0, + "wild_shots": 0, + "accidents": 0, + "total_ammo_used": 0, + "shot_at": 0, + "lucky_shots": 0, + "luck": 0, + "detector": 0, + "silencer": 0, + "sunglasses": 0, + "clothes": 0, + "grease": 0, + "brush": 0, + "mirror": 0, + "sand": 0, + "water": 0, + "sabotage": 0, + "life_insurance": 0, + "liability": 0, + "decoy": 0, + "bread": 0, + "duck_detector": 0, + "mechanical": 0, + "shots": 6, + "max_shots": 6, + "reload_time": 5.0, + "ducks_shot": 0, + "ducks_befriended": 0, + "accuracy_bonus": 0, + "xp_bonus": 0, + "charm_bonus": 0, + "exp": 0, + "money": 100, + "last_hunt": 0, + "last_reload": 0, + "level": 1, + "ignored_users": [], + "confiscated_count": 0 } }, - "last_save": "1757771132.8947892" + "last_save": "1758314578.1957033" } \ No newline at end of file diff --git a/duckhunt.log b/duckhunt.log new file mode 100644 index 0000000..8037e9f --- /dev/null +++ b/duckhunt.log @@ -0,0 +1,198 @@ +2025-09-19 21:04:25,978 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:04:25,978 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:04:25,978 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:04:25,980 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 21 players from duckhunt.json +2025-09-19 21:04:32,607 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:04:33,747 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:05:02,022 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:05:02,086 [ERROR ] DuckHuntBot - run:898: Bot error: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:06:16,216 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:06:16,217 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:06:16,221 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 21 players from duckhunt.json +2025-09-19 21:06:17,222 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:06:17,223 [INFO ] DuckHuntBot - run:908: Shutting down bot... +2025-09-19 21:08:34,809 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:08:34,810 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:08:34,817 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 21 players from duckhunt.json +2025-09-19 21:08:34,977 [INFO ] DuckHuntBot - connect:92: Connected to irc.libera.chat:6667 +2025-09-19 21:08:35,507 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:08:35,815 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:08:35,816 [INFO ] DuckHuntBot - message_loop:960: Message loop cancelled +2025-09-19 21:08:35,817 [INFO ] DuckHuntBot - message_loop:966: Message loop ended +2025-09-19 21:08:35,818 [INFO ] DuckHuntBot.Game - duck_timeout_checker:295: Duck timeout checker cancelled +2025-09-19 21:08:35,819 [INFO ] DuckHuntBot - run:899: Main loop cancelled +2025-09-19 21:08:35,821 [INFO ] DuckHuntBot - run:909: Shutting down bot... +2025-09-19 21:08:35,822 [INFO ] DuckHuntBot.Game - spawn_ducks:250: Duck spawning loop cancelled +2025-09-19 21:08:35,830 [INFO ] DuckHuntBot - run:925: Database saved +2025-09-19 21:08:35,831 [INFO ] DuckHuntBot - run:935: IRC connection closed +2025-09-19 21:08:35,831 [INFO ] DuckHuntBot - run:939: Bot shutdown complete +2025-09-19 21:08:44,430 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:08:44,431 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:08:44,431 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:08:44,432 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 21 players from duckhunt.json +2025-09-19 21:08:44,740 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:08:49,547 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:08:49,558 [INFO ] DuckHuntBot - run:899: Main loop cancelled +2025-09-19 21:08:49,559 [INFO ] DuckHuntBot - run:909: Shutting down bot... +2025-09-19 21:08:49,559 [INFO ] DuckHuntBot - message_loop:960: Message loop cancelled +2025-09-19 21:08:49,560 [INFO ] DuckHuntBot - message_loop:966: Message loop ended +2025-09-19 21:08:49,560 [INFO ] DuckHuntBot.Game - spawn_ducks:250: Duck spawning loop cancelled +2025-09-19 21:08:49,560 [INFO ] DuckHuntBot.Game - duck_timeout_checker:295: Duck timeout checker cancelled +2025-09-19 21:08:49,563 [INFO ] DuckHuntBot - run:925: Database saved +2025-09-19 21:08:49,655 [ERROR ] DuckHuntBot - run:937: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:08:49,655 [INFO ] DuckHuntBot - run:939: Bot shutdown complete +2025-09-19 21:09:05,620 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:09:05,621 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:09:05,621 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:09:05,622 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 21 players from duckhunt.json +2025-09-19 21:09:05,871 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:09:06,058 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:09:09,738 [ERROR ] DuckHuntBot - handle_command:287: Error in command handler: 'shots' +2025-09-19 21:09:17,085 [INFO ] DuckHuntBot.Game - spawn_duck_now:203: Spawned normal duck in #ct +2025-09-19 21:09:17,085 [DEBUG ] DuckHuntBot.Game - send_duck_alerts:217: Duck alerts for normal duck in #ct +2025-09-19 21:09:22,905 [INFO ] DuckHuntBot.Game - spawn_duck_now:203: Spawned rare duck in #ct +2025-09-19 21:09:22,906 [DEBUG ] DuckHuntBot.Game - send_duck_alerts:217: Duck alerts for rare duck in #ct +2025-09-19 21:09:24,398 [INFO ] DuckHuntBot.Game - spawn_duck_now:203: Spawned rare duck in #ct +2025-09-19 21:09:24,398 [DEBUG ] DuckHuntBot.Game - send_duck_alerts:217: Duck alerts for rare duck in #ct +2025-09-19 21:09:25,076 [DEBUG ] DuckHuntBot.Game - spawn_duck_now:161: Max ducks already in #ct +2025-09-19 21:10:05,877 [DEBUG ] DuckHuntBot.Game - duck_timeout_checker:290: Duck timed out in #ct +2025-09-19 21:10:35,879 [DEBUG ] DuckHuntBot.Game - duck_timeout_checker:290: Duck timed out in #ct +2025-09-19 21:10:35,880 [DEBUG ] DuckHuntBot.Game - duck_timeout_checker:290: Duck timed out in #ct +2025-09-19 21:10:53,732 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:10:53,764 [INFO ] DuckHuntBot - run:899: Main loop cancelled +2025-09-19 21:10:53,768 [INFO ] DuckHuntBot - run:909: Shutting down bot... +2025-09-19 21:10:53,768 [INFO ] DuckHuntBot - message_loop:960: Message loop cancelled +2025-09-19 21:10:53,770 [INFO ] DuckHuntBot - message_loop:966: Message loop ended +2025-09-19 21:10:53,771 [INFO ] DuckHuntBot.Game - spawn_ducks:250: Duck spawning loop cancelled +2025-09-19 21:10:53,775 [INFO ] DuckHuntBot.Game - duck_timeout_checker:295: Duck timeout checker cancelled +2025-09-19 21:10:53,789 [INFO ] DuckHuntBot - run:925: Database saved +2025-09-19 21:10:53,853 [ERROR ] DuckHuntBot - run:937: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:10:53,854 [INFO ] DuckHuntBot - run:939: Bot shutdown complete +2025-09-19 21:12:51,877 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:12:51,879 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:12:51,881 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:12:51,887 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:12:52,184 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:12:52,366 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:13:21,643 [INFO ] DuckHuntBot - signal_handler:188: Received signal 15, shutting down... +2025-09-19 21:13:21,681 [INFO ] DuckHuntBot - run:899: Main loop cancelled +2025-09-19 21:13:21,682 [INFO ] DuckHuntBot - run:909: Shutting down bot... +2025-09-19 21:13:21,684 [INFO ] DuckHuntBot - message_loop:960: Message loop cancelled +2025-09-19 21:13:21,685 [INFO ] DuckHuntBot - message_loop:966: Message loop ended +2025-09-19 21:13:21,686 [INFO ] DuckHuntBot.Game - spawn_ducks:250: Duck spawning loop cancelled +2025-09-19 21:13:21,686 [INFO ] DuckHuntBot.Game - duck_timeout_checker:295: Duck timeout checker cancelled +2025-09-19 21:13:21,690 [INFO ] DuckHuntBot - run:925: Database saved +2025-09-19 21:13:21,752 [ERROR ] DuckHuntBot - run:937: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:13:21,753 [INFO ] DuckHuntBot - run:939: Bot shutdown complete +2025-09-19 21:13:27,996 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:13:27,997 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:13:27,997 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:13:27,999 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:13:28,246 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:13:28,721 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:18:41,831 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:18:41,878 [INFO ] DuckHuntBot - run:899: Main loop cancelled +2025-09-19 21:18:41,879 [INFO ] DuckHuntBot - run:909: Shutting down bot... +2025-09-19 21:18:41,879 [INFO ] DuckHuntBot - message_loop:960: Message loop cancelled +2025-09-19 21:18:41,880 [INFO ] DuckHuntBot - message_loop:966: Message loop ended +2025-09-19 21:18:41,881 [INFO ] DuckHuntBot.Game - spawn_ducks:250: Duck spawning loop cancelled +2025-09-19 21:18:41,881 [INFO ] DuckHuntBot.Game - duck_timeout_checker:295: Duck timeout checker cancelled +2025-09-19 21:18:41,902 [INFO ] DuckHuntBot - run:925: Database saved +2025-09-19 21:18:41,987 [ERROR ] DuckHuntBot - run:937: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:18:41,988 [INFO ] DuckHuntBot - run:939: Bot shutdown complete +2025-09-19 21:19:02,499 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:19:02,500 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:19:02,501 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:19:02,504 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:19:02,778 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:19:03,277 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:19:12,233 [INFO ] DuckHuntBot - signal_handler:188: Received signal 15, shutting down... +2025-09-19 21:19:12,323 [INFO ] DuckHuntBot - run:1012: Main loop cancelled +2025-09-19 21:19:12,324 [INFO ] DuckHuntBot - run:1022: Shutting down bot... +2025-09-19 21:19:12,324 [INFO ] DuckHuntBot - message_loop:1073: Message loop cancelled +2025-09-19 21:19:12,324 [INFO ] DuckHuntBot - message_loop:1079: Message loop ended +2025-09-19 21:19:12,324 [INFO ] DuckHuntBot.Game - duck_timeout_checker:306: Duck timeout checker cancelled +2025-09-19 21:19:12,324 [INFO ] DuckHuntBot.Game - spawn_ducks:261: Duck spawning loop cancelled +2025-09-19 21:19:12,327 [INFO ] DuckHuntBot - run:1038: Database saved +2025-09-19 21:19:12,383 [ERROR ] DuckHuntBot - run:1050: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:19:12,383 [INFO ] DuckHuntBot - run:1052: Bot shutdown complete +2025-09-19 21:19:43,902 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:19:43,903 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:19:43,903 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:19:43,904 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:19:44,156 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:19:44,322 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:19:56,638 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:19:56,731 [INFO ] DuckHuntBot - run:1012: Main loop cancelled +2025-09-19 21:19:56,731 [INFO ] DuckHuntBot - run:1022: Shutting down bot... +2025-09-19 21:19:56,732 [INFO ] DuckHuntBot - message_loop:1073: Message loop cancelled +2025-09-19 21:19:56,732 [INFO ] DuckHuntBot - message_loop:1079: Message loop ended +2025-09-19 21:19:56,733 [INFO ] DuckHuntBot.Game - spawn_ducks:261: Duck spawning loop cancelled +2025-09-19 21:19:56,733 [INFO ] DuckHuntBot.Game - duck_timeout_checker:306: Duck timeout checker cancelled +2025-09-19 21:19:56,738 [INFO ] DuckHuntBot - run:1038: Database saved +2025-09-19 21:19:56,794 [ERROR ] DuckHuntBot - run:1050: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:19:56,795 [INFO ] DuckHuntBot - run:1052: Bot shutdown complete +2025-09-19 21:21:50,220 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:21:50,221 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:21:50,221 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:21:50,224 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:21:50,526 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:21:52,130 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:26:33,004 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:26:33,069 [INFO ] DuckHuntBot - run:1012: Main loop cancelled +2025-09-19 21:26:33,070 [INFO ] DuckHuntBot - run:1022: Shutting down bot... +2025-09-19 21:26:33,070 [INFO ] DuckHuntBot - message_loop:1073: Message loop cancelled +2025-09-19 21:26:33,071 [INFO ] DuckHuntBot - message_loop:1079: Message loop ended +2025-09-19 21:26:33,074 [INFO ] DuckHuntBot.Game - spawn_ducks:261: Duck spawning loop cancelled +2025-09-19 21:26:33,075 [INFO ] DuckHuntBot.Game - duck_timeout_checker:306: Duck timeout checker cancelled +2025-09-19 21:26:33,097 [INFO ] DuckHuntBot - run:1038: Database saved +2025-09-19 21:26:33,184 [ERROR ] DuckHuntBot - run:1050: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:26:33,185 [INFO ] DuckHuntBot - run:1052: Bot shutdown complete +2025-09-19 21:27:12,029 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:27:12,030 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:27:12,030 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:27:12,033 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:27:12,302 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:27:12,685 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:27:21,684 [INFO ] DuckHuntBot - signal_handler:188: Received signal 15, shutting down... +2025-09-19 21:27:21,734 [INFO ] DuckHuntBot - run:1000: Main loop cancelled +2025-09-19 21:27:21,734 [INFO ] DuckHuntBot - run:1010: Shutting down bot... +2025-09-19 21:27:21,735 [INFO ] DuckHuntBot.Game - duck_timeout_checker:307: Duck timeout checker cancelled +2025-09-19 21:27:21,735 [INFO ] DuckHuntBot - message_loop:1061: Message loop cancelled +2025-09-19 21:27:21,735 [INFO ] DuckHuntBot - message_loop:1067: Message loop ended +2025-09-19 21:27:21,735 [INFO ] DuckHuntBot.Game - spawn_ducks:261: Duck spawning loop cancelled +2025-09-19 21:27:21,743 [INFO ] DuckHuntBot - run:1026: Database saved +2025-09-19 21:27:21,799 [ERROR ] DuckHuntBot - run:1038: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:27:21,800 [INFO ] DuckHuntBot - run:1040: Bot shutdown complete +2025-09-19 21:27:36,083 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:27:36,084 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:27:36,084 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:27:36,086 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:27:36,338 [INFO ] DuckHuntBot - connect:92: Connected to irc.rizon.net:6697 +2025-09-19 21:27:36,504 [INFO ] DuckHuntBot - handle_message:205: Successfully registered with IRC server +2025-09-19 21:30:40,213 [INFO ] DuckHuntBot - signal_handler:188: Received signal 2, shutting down... +2025-09-19 21:30:40,256 [INFO ] DuckHuntBot - run:1000: Main loop cancelled +2025-09-19 21:30:40,257 [INFO ] DuckHuntBot - run:1010: Shutting down bot... +2025-09-19 21:30:40,257 [INFO ] DuckHuntBot - message_loop:1061: Message loop cancelled +2025-09-19 21:30:40,257 [INFO ] DuckHuntBot - message_loop:1067: Message loop ended +2025-09-19 21:30:40,257 [INFO ] DuckHuntBot.Game - spawn_ducks:261: Duck spawning loop cancelled +2025-09-19 21:30:40,257 [INFO ] DuckHuntBot.Game - duck_timeout_checker:307: Duck timeout checker cancelled +2025-09-19 21:30:40,262 [INFO ] DuckHuntBot - run:1026: Database saved +2025-09-19 21:30:40,318 [ERROR ] DuckHuntBot - run:1038: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:30:40,319 [INFO ] DuckHuntBot - run:1040: Bot shutdown complete +2025-09-19 21:42:07,003 [INFO ] DuckHuntBot - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:42:07,005 [INFO ] SASL - setup_logger:65: Enhanced logging system initialized with file rotation +2025-09-19 21:42:07,005 [INFO ] DuckHuntBot - main:26: 🦆 Starting DuckHunt Bot... +2025-09-19 21:42:07,009 [INFO ] DuckHuntBot.DB - load_database:40: Loaded 22 players from duckhunt.json +2025-09-19 21:42:07,297 [INFO ] DuckHuntBot - connect:95: Connected to irc.rizon.net:6697 +2025-09-19 21:42:07,739 [INFO ] DuckHuntBot - handle_message:208: Successfully registered with IRC server +2025-09-19 21:42:58,096 [INFO ] DuckHuntBot - signal_handler:191: Received signal 2, shutting down... +2025-09-19 21:42:58,194 [INFO ] DuckHuntBot.Game - spawn_ducks:261: Duck spawning loop cancelled +2025-09-19 21:42:58,194 [INFO ] DuckHuntBot - run:1128: Main loop cancelled +2025-09-19 21:42:58,194 [INFO ] DuckHuntBot - run:1138: Shutting down bot... +2025-09-19 21:42:58,195 [INFO ] DuckHuntBot.Game - duck_timeout_checker:307: Duck timeout checker cancelled +2025-09-19 21:42:58,195 [INFO ] DuckHuntBot - message_loop:1189: Message loop cancelled +2025-09-19 21:42:58,195 [INFO ] DuckHuntBot - message_loop:1195: Message loop ended +2025-09-19 21:42:58,198 [INFO ] DuckHuntBot - run:1154: Database saved +2025-09-19 21:42:58,261 [ERROR ] DuckHuntBot - run:1166: Error closing connection: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application data after close notify (_ssl.c:2685) +2025-09-19 21:42:58,262 [INFO ] DuckHuntBot - run:1168: Bot shutdown complete diff --git a/duckhunt/duckhunt.py b/duckhunt.py similarity index 76% rename from duckhunt/duckhunt.py rename to duckhunt.py index c9545d7..aa8414e 100644 --- a/duckhunt/duckhunt.py +++ b/duckhunt.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Main entry point for DuckHunt Bot +DuckHunt IRC Bot - Main Entry Point """ import asyncio @@ -11,14 +11,18 @@ import os # Add src directory to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) -from src.duckhuntbot import IRCBot +from src.duckhuntbot import DuckHuntBot + def main(): + """Main entry point for DuckHunt Bot""" try: + # Load configuration with open('config.json') as f: config = json.load(f) - bot = IRCBot(config) + # Create and run bot + bot = DuckHuntBot(config) bot.logger.info("🦆 Starting DuckHunt Bot...") # Run the bot @@ -33,5 +37,6 @@ def main(): print(f"❌ Error: {e}") sys.exit(1) + if __name__ == '__main__': main() diff --git a/duckhunt/CONFIG_GUIDE.md b/duckhunt/CONFIG_GUIDE.md deleted file mode 100644 index d45e7aa..0000000 --- a/duckhunt/CONFIG_GUIDE.md +++ /dev/null @@ -1,272 +0,0 @@ -# DuckHunt Bot Configuration Guide - -This document explains all the configuration options available in `config.json` to customize your DuckHunt bot experience. - -## Basic IRC Settings -```json -{ - "server": "irc.rizon.net", // IRC server hostname - "port": 6697, // IRC server port (6667 for non-SSL, 6697 for SSL) - "nick": "DuckHunt", // Bot's nickname - "channels": ["#channel"], // List of channels to join - "ssl": true, // Enable SSL/TLS connection - "password": "", // Server password (if required) - "admins": ["nick1", "nick2"] // List of admin nicknames -} -``` - -## SASL Authentication -```json -"sasl": { - "enabled": true, // Enable SASL authentication - "username": "botaccount", // NickServ account username - "password": "botpassword" // NickServ account password -} -``` - -## Duck Spawning Configuration -```json -"duck_spawn_min": 1800, // Minimum time between duck spawns (seconds) -"duck_spawn_max": 5400, // Maximum time between duck spawns (seconds) -"duck_timeout_min": 45, // Minimum time duck stays alive (seconds) -"duck_timeout_max": 75, // Maximum time duck stays alive (seconds) -"sleep_hours": [], // Hours when no ducks spawn [start_hour, end_hour] -"max_ducks_per_channel": 3, // Maximum ducks that can exist per channel -``` - -### Duck Types -Configure different duck types with spawn rates and rewards: -```json -"duck_types": { - "normal": { - "enabled": true, // Enable this duck type - "spawn_rate": 70, // Percentage chance to spawn (out of 100) - "xp_reward": 10, // XP gained when caught - "health": 1 // How many hits to kill - }, - "golden": { - "enabled": true, - "spawn_rate": 8, - "xp_reward": 50, - "health": 1 - } - // ... more duck types -} -``` - -## Duck Befriending System -```json -"befriending": { - "enabled": true, // Enable !bef command - "base_success_rate": 65, // Base chance of successful befriend (%) - "max_success_rate": 90, // Maximum possible success rate (%) - "level_bonus_per_level": 2, // Success bonus per player level (%) - "level_bonus_cap": 20, // Maximum level bonus (%) - "luck_bonus_per_point": 3, // Success bonus per luck point (%) - "xp_reward": 8, // XP gained on successful befriend - "xp_reward_min": 1, // Minimum XP from befriending - "xp_reward_max": 3, // Maximum XP from befriending - "failure_xp_penalty": 1, // XP lost on failed befriend - "scared_away_chance": 10, // Chance duck flies away on failure (%) - "lucky_item_chance": 5 // Base chance for lucky item drops (%) -} -``` - -## Shooting Mechanics -```json -"shooting": { - "enabled": true, // Enable !bang command - "base_accuracy": 85, // Starting player accuracy (%) - "base_reliability": 90, // Starting gun reliability (%) - "jam_chance_base": 10, // Base gun jam chance (%) - "friendly_fire_enabled": true, // Allow shooting other players - "friendly_fire_chance": 5, // Chance of friendly fire (%) - "reflex_shot_bonus": 5, // Bonus for quick shots (%) - "miss_xp_penalty": 5, // XP lost on missed shot - "wild_shot_xp_penalty": 10, // XP lost on wild shot - "teamkill_xp_penalty": 20 // XP lost on team kill -} -``` - -## Weapon System -```json -"weapons": { - "enabled": true, // Enable weapon mechanics - "starting_weapon": "pistol", // Default weapon for new players - "starting_ammo": 6, // Starting ammo count - "max_ammo_base": 6, // Base maximum ammo capacity - "starting_chargers": 2, // Starting reload items - "max_chargers_base": 2, // Base maximum reload items - "durability_enabled": true, // Enable weapon wear/breaking - "confiscation_enabled": true // Allow admin gun confiscation -} -``` - -## Economy System -```json -"economy": { - "enabled": true, // Enable coin/shop system - "starting_coins": 100, // Coins for new players - "shop_enabled": true, // Enable !shop command - "trading_enabled": true, // Enable !trade command - "theft_enabled": true, // Enable !steal command - "theft_success_rate": 30, // Chance theft succeeds (%) - "theft_penalty": 50, // Coins lost if theft fails - "banking_enabled": true, // Enable banking system - "interest_rate": 5, // Bank interest rate (%) - "loan_enabled": true // Enable loan system -} -``` - -## Player Progression -```json -"progression": { - "enabled": true, // Enable XP/leveling system - "max_level": 40, // Maximum player level - "xp_multiplier": 1.0, // Global XP multiplier - "level_benefits_enabled": true, // Level bonuses (accuracy, etc.) - "titles_enabled": true, // Show player titles - "prestige_enabled": false // Enable prestige system -} -``` - -## Karma System -```json -"karma": { - "enabled": true, // Enable karma tracking - "hit_bonus": 2, // Karma for successful shots - "golden_hit_bonus": 5, // Karma for golden duck hits - "teamkill_penalty": 10, // Karma lost for team kills - "wild_shot_penalty": 3, // Karma lost for wild shots - "miss_penalty": 1, // Karma lost for misses - "befriend_success_bonus": 2, // Karma for successful befriends - "befriend_fail_penalty": 1 // Karma lost for failed befriends -} -``` - -## Items and Powerups -```json -"items": { - "enabled": true, // Enable item system - "lucky_items_enabled": true, // Enable lucky item drops - "lucky_item_base_chance": 5, // Base lucky item chance (%) - "detector_enabled": true, // Enable duck detector item - "silencer_enabled": true, // Enable silencer item - "sunglasses_enabled": true, // Enable sunglasses item - "explosive_ammo_enabled": true, // Enable explosive ammo - "sabotage_enabled": true, // Enable sabotage mechanics - "insurance_enabled": true, // Enable insurance system - "decoy_enabled": true // Enable decoy ducks -} -``` - -## Social Features -```json -"social": { - "leaderboards_enabled": true, // Enable !top command - "duck_alerts_enabled": true, // Enable duck spawn notifications - "private_messages_enabled": true, // Allow PM commands - "statistics_sharing_enabled": true, // Enable !stats sharing - "achievements_enabled": false // Enable achievement system -} -``` - -## Moderation Features -```json -"moderation": { - "ignore_system_enabled": true, // Enable !ignore command - "rate_limiting_enabled": true, // Prevent command spam - "rate_limit_cooldown": 2.0, // Seconds between commands - "admin_commands_enabled": true, // Enable admin commands - "ban_system_enabled": true, // Enable player banning - "database_reset_enabled": true, // Allow database resets - "admin_rearm_gives_full_ammo": true, // Admin !rearm gives full ammo - "admin_rearm_gives_full_chargers": true // Admin !rearm gives full chargers -} -``` - -## Advanced Features -```json -"advanced": { - "gun_jamming_enabled": true, // Enable gun jam mechanics - "weather_effects_enabled": false, // Weather affecting gameplay - "seasonal_events_enabled": false, // Special holiday events - "daily_challenges_enabled": false, // Daily quest system - "guild_system_enabled": false, // Player guilds/teams - "pvp_enabled": false // Player vs player combat -} -``` - -## Message Customization -```json -"messages": { - "custom_duck_messages_enabled": true, // Varied duck spawn messages - "color_enabled": true, // IRC color codes in messages - "emoji_enabled": true, // Unicode emojis in messages - "verbose_messages": true, // Detailed action messages - "success_sound_effects": true // Text sound effects -} -``` - -## Database Settings -```json -"database": { - "auto_save_enabled": true, // Automatic database saving - "auto_save_interval": 300, // Auto-save every N seconds - "backup_enabled": true, // Create database backups - "backup_interval": 3600, // Backup every N seconds - "compression_enabled": false // Compress database files -} -``` - -## Debug Options -```json -"debug": { - "debug_mode": false, // Enable debug features - "verbose_logging": false, // Extra detailed logs - "command_logging": false, // Log all commands - "performance_monitoring": false // Track performance metrics -} -``` - -## Configuration Tips - -1. **Duck Spawn Timing**: Adjust `duck_spawn_min/max` based on channel activity -2. **Difficulty**: Lower `befriending.base_success_rate` for harder gameplay -3. **Economy**: Adjust XP rewards to balance progression -4. **Features**: Disable unwanted features by setting `enabled: false` -5. **Performance**: Enable rate limiting and disable verbose logging for busy channels -6. **Testing**: Use debug mode and shorter spawn times for testing - -## Example Configurations - -### Casual Server (Easy) -```json -"befriending": { - "base_success_rate": 80, - "max_success_rate": 95 -}, -"economy": { - "starting_coins": 200 -} -``` - -### Competitive Server (Hard) -```json -"befriending": { - "base_success_rate": 45, - "max_success_rate": 75 -}, -"shooting": { - "base_accuracy": 70, - "friendly_fire_chance": 10 -} -``` - -### Minimal Features -```json -"befriending": { "enabled": false }, -"items": { "enabled": false }, -"karma": { "enabled": false }, -"social": { "leaderboards_enabled": false } -``` diff --git a/duckhunt/README.md b/duckhunt/README.md deleted file mode 100644 index f17e78d..0000000 --- a/duckhunt/README.md +++ /dev/null @@ -1,172 +0,0 @@ -# 🦆 DuckHunt IRC Bot - -A feature-rich IRC game bot where players hunt ducks, upgrade weapons, trade items, and compete on leaderboards! - -## 🚀 Features - -### 🎯 Core Game Mechanics -- **Different Duck Types**: Common, Rare, Golden, and Armored ducks with varying rewards -- **Weapon System**: Multiple weapon types (Basic Gun, Shotgun, Rifle) with durability -- **Ammunition Types**: Standard, Rubber Bullets, Explosive Rounds -- **Weapon Attachments**: Laser Sight, Extended Magazine, Bipod -- **Accuracy & Reliability**: Skill-based hit/miss and reload failure mechanics - -### 🏦 Economy System -- **Shop**: Buy/sell weapons, attachments, and upgrades -- **Banking**: Deposit coins for interest, take loans -- **Trading**: Trade coins and items with other players -- **Insurance**: Protect your equipment from damage -- **Hunting Licenses**: Unlock premium features and bonuses - -### 👤 Player Progression -- **Hunter Levels**: Gain XP and level up for better abilities -- **Account System**: Register accounts with password authentication -- **Multiple Auth Methods**: Nick-based, hostmask, or registered account -- **Persistent Stats**: All progress saved to SQLite database - -### 🏆 Social Features -- **Leaderboards**: Compete for top rankings -- **Duck Alerts**: Get notified when rare ducks spawn -- **Sabotage**: Interfere with other players (for a cost!) -- **Comprehensive Help**: Detailed command reference - -## 📋 Requirements - -- Python 3.7+ -- asyncio support -- SQLite3 (included with Python) - -## 🛠️ Installation - -1. Clone or download the bot files -2. Edit `config.json` with your IRC server details: - ```json - { - "server": "irc.libera.chat", - "port": 6697, - "nick": "DuckHuntBot", - "channels": ["#yourchannel"], - "ssl": true, - "sasl": false, - "password": "", - "duck_spawn_min": 60, - "duck_spawn_max": 300 - } - ``` - -3. Test the bot: - ```bash - python test_bot.py - ``` - -4. Run the bot: - ```bash - python duckhunt.py - ``` - -## 🎮 Commands - -### 🎯 Hunting -- `!bang` - Shoot at a duck (accuracy-based hit/miss) -- `!reload` - Reload weapon (can fail based on reliability) -- `!catch` - Catch a duck with your hands -- `!bef` - Befriend a duck instead of shooting - -### 🛒 Economy -- `!shop` - View available items -- `!buy ` - Purchase items -- `!sell ` - Sell equipment -- `!bank` - Banking services -- `!trade ` - Trade with others - -### 📊 Stats & Info -- `!stats` - Detailed combat statistics -- `!duckstats` - Personal hunting record -- `!leaderboard` - Top players ranking -- `!license` - Hunting license management - -### ⚙️ Settings -- `!alerts` - Toggle duck spawn notifications -- `!help` - Complete command reference - -### 🔐 Account System -- `/msg BotNick register ` - Register account -- `/msg BotNick identify ` - Login to account - -### 🎮 Advanced -- `!sabotage ` - Sabotage another hunter's weapon - -## 🗂️ File Structure - -``` -duckhunt/ -├── src/ -│ ├── duckhuntbot.py # Main IRC bot logic -│ ├── game.py # Game mechanics and commands -│ ├── db.py # SQLite database handling -│ ├── auth.py # Authentication system -│ ├── items.py # Duck types, weapons, attachments -│ ├── logging_utils.py # Colored logging setup -│ └── utils.py # IRC message parsing -├── config.json # Bot configuration -├── duckhunt.py # Main entry point -├── test_bot.py # Test script -└── README.md # This file -``` - -## 🎯 Game Balance - -### Duck Types & Rewards -- **Common Duck** 🦆: 1 coin, 10 XP (70% spawn rate) -- **Rare Duck** 🦆✨: 3 coins, 25 XP (20% spawn rate) -- **Golden Duck** 🥇🦆: 10 coins, 50 XP (8% spawn rate) -- **Armored Duck** 🛡️🦆: 15 coins, 75 XP (2% spawn rate, 3 health) - -### Weapon Stats -- **Basic Gun**: 0% accuracy bonus, 100 durability, 1 attachment slot -- **Shotgun**: -10% accuracy, 80 durability, 2 slots, spread shot -- **Rifle**: +20% accuracy, 120 durability, 3 slots - -### Progression -- Players start with 100 coins and basic stats -- Level up by gaining XP from successful hunts -- Unlock better equipment and abilities as you progress - -## 🔧 Configuration - -Edit `config.json` to customize: -- IRC server and channels -- Duck spawn timing (min/max seconds) -- SSL and SASL authentication -- Bot nickname - -## 🛡️ Security - -- Passwords are hashed with PBKDF2 -- Account data stored separately from temporary nick data -- Multiple authentication methods supported -- Database uses prepared statements to prevent injection - -## 🐛 Troubleshooting - -1. **Bot won't connect**: Check server/port in config.json -2. **Database errors**: Ensure write permissions in bot directory -3. **Commands not working**: Verify bot has joined the channel -4. **Test failures**: Run `python test_bot.py` to diagnose issues - -## 🎖️ Contributing - -Feel free to add new features: -- More duck types and weapons -- Additional mini-games -- Seasonal events -- Guild/team systems -- Advanced trading mechanics - -## 📄 License - -This bot is provided as-is for educational and entertainment purposes. - ---- - -🦆 **Happy Hunting!** 🦆 diff --git a/duckhunt/__pycache__/simple_duckhunt.cpython-312.pyc b/duckhunt/__pycache__/simple_duckhunt.cpython-312.pyc deleted file mode 100644 index 5ed5d1246ef9fc4cc7f08ecb3f2c4874850b0aff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 141195 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

ug&@o?~ZC=gL@JAtDwSQFH;5E)YeD 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
bc;o>OHV8&JI`JSkfEy5+XxAXWnJGuKaODW% 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 diff --git a/duckhunt/config_backup.json b/duckhunt/config_backup.json deleted file mode 100644 index 559a49c..0000000 --- a/duckhunt/config_backup.json +++ /dev/null @@ -1,216 +0,0 @@ -{ - "server": "irc.rizon.net", - "port": 6697, - "nick": "DuckHunt", - "channels": ["#computertech"], - "ssl": true, - "sasl": { - "enabled": true, - "username": "duckhunt", - "password": "duckhunt//789//" - }, - "password": "", - "admins": ["peorth", "computertech"], - - "_comment_duck_spawning": "Duck spawning configuration", - "duck_spawn_min": 1800, - "duck_spawn_max": 5400, - "duck_timeout_min": 45, - "duck_timeout_max": 75, - "duck_types": { - "normal": { - "enabled": true, - "spawn_rate": 70, - "xp_reward": 10, - "coin_reward": 1, - "health": 1 - }, - "golden": { - "enabled": true, - "spawn_rate": 8, - "xp_reward": 50, - "coin_reward": 10, - "health": 1 - }, - "armored": { - "enabled": true, - "spawn_rate": 2, - "xp_reward": 75, - "coin_reward": 15, - "health": 3 - }, - "rare": { - "enabled": true, - "spawn_rate": 20, - "xp_reward": 25, - "coin_reward": 3, - "health": 1 - } - }, - "sleep_hours": [], - "max_ducks_per_channel": 3, - - "_comment_befriending": "Duck befriending configuration", - "befriending": { - "enabled": true, - "base_success_rate": 65, - "max_success_rate": 90, - "level_bonus_per_level": 2, - "level_bonus_cap": 20, - "luck_bonus_per_point": 3, - "xp_reward": 8, - "coin_reward_min": 1, - "coin_reward_max": 2, - "failure_xp_penalty": 1, - "scared_away_chance": 10, - "lucky_item_chance": 5 - }, - - "_comment_shooting": "Shooting mechanics configuration", - "shooting": { - "enabled": true, - "base_accuracy": 85, - "base_reliability": 90, - "jam_chance_base": 10, - "friendly_fire_enabled": true, - "friendly_fire_chance": 5, - "reflex_shot_bonus": 5, - "miss_xp_penalty": 5, - "wild_shot_xp_penalty": 10, - "teamkill_xp_penalty": 20 - }, - - "_comment_weapons": "Weapon system configuration", - "weapons": { - "enabled": true, - "starting_weapon": "pistol", - "starting_ammo": 6, - "max_ammo_base": 6, - "starting_chargers": 2, - "max_chargers_base": 2, - "durability_enabled": true, - "confiscation_enabled": true - }, - - "_comment_economy": "Economy and shop configuration", - "economy": { - "enabled": true, - "starting_coins": 100, - "shop_enabled": true, - "trading_enabled": true, - "theft_enabled": true, - "theft_success_rate": 30, - "theft_penalty": 50, - "banking_enabled": true, - "interest_rate": 5, - "loan_enabled": true - }, - - "_comment_progression": "Player progression configuration", - "progression": { - "enabled": true, - "max_level": 40, - "xp_multiplier": 1.0, - "level_benefits_enabled": true, - "titles_enabled": true, - "prestige_enabled": false - }, - - "_comment_karma": "Karma system configuration", - "karma": { - "enabled": true, - "hit_bonus": 2, - "golden_hit_bonus": 5, - "teamkill_penalty": 10, - "wild_shot_penalty": 3, - "miss_penalty": 1, - "befriend_success_bonus": 2, - "befriend_fail_penalty": 1 - }, - - "_comment_items": "Items and powerups configuration", - "items": { - "enabled": true, - "lucky_items_enabled": true, - "lucky_item_base_chance": 5, - "detector_enabled": true, - "silencer_enabled": true, - "sunglasses_enabled": true, - "explosive_ammo_enabled": true, - "sabotage_enabled": true, - "insurance_enabled": true, - "decoy_enabled": true - }, - - "_comment_social": "Social features configuration", - "social": { - "leaderboards_enabled": true, - "duck_alerts_enabled": true, - "private_messages_enabled": true, - "statistics_sharing_enabled": true, - "achievements_enabled": false - }, - - "_comment_moderation": "Moderation and admin features", - "moderation": { - "ignore_system_enabled": true, - "rate_limiting_enabled": true, - "rate_limit_cooldown": 2.0, - "admin_commands_enabled": true, - "ban_system_enabled": true, - "database_reset_enabled": true - }, - - "_comment_advanced": "Advanced game mechanics", - "advanced": { - "gun_jamming_enabled": true, - "weather_effects_enabled": false, - "seasonal_events_enabled": false, - "daily_challenges_enabled": false, - "guild_system_enabled": false, - "pvp_enabled": false - }, - - "_comment_messages": "Message customization", - "messages": { - "custom_duck_messages_enabled": true, - "color_enabled": true, - "emoji_enabled": true, - "verbose_messages": true, - "success_sound_effects": true - }, - - "_comment_database": "Database and persistence", - "database": { - "auto_save_enabled": true, - "auto_save_interval": 300, - "backup_enabled": true, - "backup_interval": 3600, - "compression_enabled": false - }, - - "_comment_debug": "Debug and logging options", - "debug": { - "debug_mode": false, - "verbose_logging": false, - "command_logging": false, - "performance_monitoring": false - } -}c.rizon.net", - "port": 6697, - "nick": "DuckHunt", - "channels": ["#computertech"], - "ssl": true, - "sasl": { - "enabled": true, - "username": "duckhunt", - "password": "duckhunt//789//" - }, - "password": "", - "admins": ["colby", "computertech"], - "duck_spawn_min": 1800, - "duck_spawn_max": 5400, - "duck_timeout_min": 45, - "duck_timeout_max": 75, - "_comment": "Run with: python3 simple_duckhunt.py | Admins config-only | Private admin: /msg DuckHuntBot restart|quit|launch | Duck timeout: random between min-max seconds" -} diff --git a/duckhunt/config_local.json b/duckhunt/config_local.json deleted file mode 100644 index 0c318f4..0000000 --- a/duckhunt/config_local.json +++ /dev/null @@ -1,205 +0,0 @@ -{ - "server": "irc.rizon.net", - "port": 6697, - "nick": "DuckHunt", - "channels": ["#computertech"], - "ssl": true, - "sasl": { - "enabled": true, - "username": "duckhunt", - "password": "duckhunt//789//" - }, - "password": "", - "admins": ["peorth", "computertech", "colby"], - - "_comment_duck_spawning": "Duck spawning configuration", - "duck_spawn_min": 1800, - "duck_spawn_max": 5400, - "duck_timeout_min": 45, - "duck_timeout_max": 75, - "duck_types": { - "normal": { - "spawn_chance": 0.6, - "xp_reward": 10, - "difficulty": 1.0, - "flee_time": 15, - "messages": ["・゜゜・。。・゜゜\\\\_o< QUACK!"] - }, - "fast": { - "spawn_chance": 0.25, - "xp_reward": 15, - "difficulty": 1.5, - "flee_time": 8, - "messages": ["・゜゜・。。・゜゜\\\\_o< QUACK! (Fast duck!)"] - }, - "rare": { - "spawn_chance": 0.1, - "xp_reward": 30, - "difficulty": 2.0, - "flee_time": 12, - "messages": ["・゜゜・。。・゜゜\\\\_o< QUACK! (Rare duck!)"] - }, - "golden": { - "spawn_chance": 0.05, - "xp_reward": 75, - "difficulty": 3.0, - "flee_time": 10, - "messages": ["・゜゜・。。・゜゜\\\\_✪< ★ GOLDEN DUCK ★"] - } - }, - "sleep_hours": [], - "max_ducks_per_channel": 3, - - "_comment_befriending": "Duck befriending configuration", - "befriending": { - "enabled": true, - "success_chance": 0.7, - "failure_messages": [ - "The duck looked at you suspiciously and flew away!", - "The duck didn't trust you and escaped!", - "The duck was too scared and ran off!" - ], - "scared_away_chance": 0.1, - "scared_away_messages": [ - "You scared the duck away with your approach!", - "The duck was terrified and fled immediately!" - ], - "xp_reward_min": 1, - "xp_reward_max": 3 - }, - - "_comment_shooting": "Shooting mechanics configuration", - "shooting": { - "enabled": true, - "base_accuracy": 85, - "base_reliability": 90, - "jam_chance_base": 10, - "friendly_fire_enabled": true, - "friendly_fire_chance": 5, - "reflex_shot_bonus": 5, - "miss_xp_penalty": 5, - "wild_shot_xp_penalty": 10, - "teamkill_xp_penalty": 20 - }, - - "_comment_weapons": "Weapon system configuration", - "weapons": { - "enabled": true, - "starting_weapon": "pistol", - "starting_ammo": 6, - "max_ammo_base": 6, - "starting_chargers": 2, - "max_chargers_base": 2, - "durability_enabled": true, - "confiscation_enabled": true - }, - - "_comment_economy": "Economy and shop configuration", - "economy": { - "enabled": true, - "starting_coins": 100, - "shop_enabled": true, - "trading_enabled": true, - "theft_enabled": true, - "theft_success_rate": 30, - "theft_penalty": 50, - "banking_enabled": true, - "interest_rate": 5, - "loan_enabled": true, - "inventory_system_enabled": true, - "max_inventory_slots": 20 - }, - - "_comment_progression": "Player progression configuration", - "progression": { - "enabled": true, - "max_level": 40, - "xp_multiplier": 1.0, - "level_benefits_enabled": true, - "titles_enabled": true, - "prestige_enabled": false - }, - - "_comment_karma": "Karma system configuration", - "karma": { - "enabled": true, - "hit_bonus": 2, - "golden_hit_bonus": 5, - "teamkill_penalty": 10, - "wild_shot_penalty": 3, - "miss_penalty": 1, - "befriend_success_bonus": 2, - "befriend_fail_penalty": 1 - }, - - "_comment_items": "Items and powerups configuration", - "items": { - "enabled": true, - "lucky_items_enabled": true, - "lucky_item_base_chance": 5, - "detector_enabled": true, - "silencer_enabled": true, - "sunglasses_enabled": true, - "explosive_ammo_enabled": true, - "sabotage_enabled": true, - "insurance_enabled": true, - "decoy_enabled": true - }, - - "_comment_social": "Social features configuration", - "social": { - "leaderboards_enabled": true, - "duck_alerts_enabled": true, - "private_messages_enabled": true, - "statistics_sharing_enabled": true, - "achievements_enabled": false - }, - - "_comment_moderation": "Moderation and admin features", - "moderation": { - "ignore_system_enabled": true, - "rate_limiting_enabled": true, - "rate_limit_cooldown": 2.0, - "admin_commands_enabled": true, - "ban_system_enabled": true, - "database_reset_enabled": true, - "admin_rearm_gives_full_ammo": false, - "admin_rearm_gives_full_chargers":false - }, - - "_comment_advanced": "Advanced game mechanics", - "advanced": { - "gun_jamming_enabled": true, - "weather_effects_enabled": false, - "seasonal_events_enabled": false, - "daily_challenges_enabled": false, - "guild_system_enabled": false, - "pvp_enabled": false - }, - - "_comment_messages": "Message customization", - "messages": { - "custom_duck_messages_enabled": true, - "color_enabled": true, - "emoji_enabled": true, - "verbose_messages": true, - "success_sound_effects": true - }, - - "_comment_database": "Database and persistence", - "database": { - "auto_save_enabled": true, - "auto_save_interval": 300, - "backup_enabled": true, - "backup_interval": 3600, - "compression_enabled": false - }, - - "_comment_debug": "Debug and logging options", - "debug": { - "debug_mode": false, - "verbose_logging": false, - "command_logging": false, - "performance_monitoring": false - } -} diff --git a/duckhunt/config_new.json b/duckhunt/config_new.json deleted file mode 100644 index e69de29..0000000 diff --git a/duckhunt/demo_sasl.py b/duckhunt/demo_sasl.py deleted file mode 100644 index 90b0b0a..0000000 --- a/duckhunt/demo_sasl.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -""" -SASL Integration Demo for DuckHunt Bot -This script demonstrates how the modular SASL authentication works -""" - -import asyncio -import json -import sys -import os - -# Add src directory to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -from src.sasl import SASLHandler -from src.logging_utils import setup_logger - -class MockBot: - """Mock bot for testing SASL without IRC connection""" - def __init__(self, config): - self.config = config - self.logger = setup_logger("MockBot") - self.messages_sent = [] - - def send_raw(self, message): - """Mock send_raw that just logs the message""" - self.messages_sent.append(message) - self.logger.info(f"SEND: {message}") - - async def register_user(self): - """Mock registration""" - self.logger.info("Mock user registration completed") - -async def demo_sasl_flow(): - """Demonstrate the SASL authentication flow""" - print("🔐 SASL Authentication Flow Demo") - print("=" * 50) - - # Load config - with open('config.json') as f: - config = json.load(f) - - # Override with test credentials for demo - config['sasl'] = { - 'enabled': True, - 'username': 'testuser', - 'password': 'testpass123' - } - - # Create mock bot and SASL handler - bot = MockBot(config) - sasl_handler = SASLHandler(bot, config) - - print("\n1️⃣ Starting SASL negotiation...") - if await sasl_handler.start_negotiation(): - print("✅ SASL negotiation started successfully") - else: - print("❌ SASL negotiation failed to start") - return - - print("\n2️⃣ Simulating server CAP response...") - # Simulate server listing SASL capability - params = ['*', 'LS', '*'] - trailing = 'sasl multi-prefix extended-join' - await sasl_handler.handle_cap_response(params, trailing) - - print("\n3️⃣ Simulating server acknowledging SASL capability...") - # Simulate server acknowledging SASL - params = ['*', 'ACK'] - trailing = 'sasl' - await sasl_handler.handle_cap_response(params, trailing) - - print("\n4️⃣ Simulating server ready for authentication...") - # Simulate server ready for auth - params = ['+'] - await sasl_handler.handle_authenticate_response(params) - - print("\n5️⃣ Simulating successful authentication...") - # Simulate successful authentication - params = ['DuckHunt'] - trailing = 'You are now logged in as duckhunt' - await sasl_handler.handle_sasl_result('903', params, trailing) - - print(f"\n📤 Messages sent to server:") - for i, msg in enumerate(bot.messages_sent, 1): - print(f" {i}. {msg}") - - print(f"\n🔍 Authentication status: {'✅ Authenticated' if sasl_handler.is_authenticated() else '❌ Not authenticated'}") - - print("\n" + "=" * 50) - print("✨ SASL flow demonstration complete!") - -async def demo_sasl_failure(): - """Demonstrate SASL failure handling""" - print("\n\n🚫 SASL Failure Handling Demo") - print("=" * 50) - - # Create mock bot with wrong credentials - config = { - 'sasl': { - 'enabled': True, - 'username': 'testuser', - 'password': 'wrong_password' - } - } - bot = MockBot(config) - sasl_handler = SASLHandler(bot, config) - - print("\n1️⃣ Starting SASL with wrong credentials...") - await sasl_handler.start_negotiation() - - # Simulate failed authentication - params = ['DuckHunt'] - trailing = 'Invalid credentials' - await sasl_handler.handle_sasl_result('904', params, trailing) - - print(f"\n🔍 Authentication status: {'✅ Authenticated' if sasl_handler.is_authenticated() else '❌ Not authenticated'}") - print("✅ Failure handled gracefully - bot will fallback to NickServ") - -if __name__ == '__main__': - asyncio.run(demo_sasl_flow()) - asyncio.run(demo_sasl_failure()) diff --git a/duckhunt/duckhunt.db b/duckhunt/duckhunt.db deleted file mode 100644 index e00e26c4d8cb3f1b06933a07f3ad0cba3d69f966..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36864 zcmeI&Z%@-e9Ki9Gu|I$?A7~mvmRuka2vb=MPkiK@g)A_{QKCGtDcgaIV;gNpB*q7z zPkcAN4c~z;#0Na|I##l=4K+Tp`Ci)f`sZ%<``o2jd)s}rRr94dY`VveFP><3HC@-9 z3886PT&)vo4b!;F1Z$Y|FmuZ`u01;aI-S1MQd4iW^m_XH%x3!joy(bfQ{U795&{Sy zfB*srAbe4%@tDoAoCr{e4QMtK0Q7D^v%M@1r`IaeeT)Wh8 zj%8MrT)r@qNn>_a{~|ke`mR%z-qmNSuZF8*Vrf#`NVTdcZd>MtSr$9xVkuwV7n|n3 z$nRM%i`%M-lDTbV2TC2f&12iCRGRIEFD&!TkW2jL?=A=$YgZlL8Bv4hJAT{ioD?t2 z_59wJB^FzvDy?a#=-UU!Um|D=I@_ZwfG%^|eLUphj?%l8P z!tHn;W_WanWZRQ&Fy61mK~K{M! zZ`GYM>3ZGGSYP1XgG1tO)GF`)Cg%Q5dpMRb@^how-2GNN*UiM#Z8jC$YNyqzr+;W_ zAt8VO0tg_000IagfB*srAb`MsCh$OyWeUD}H5j~2`8Z!uFK@j0ry{qynicaEr+xI! zSG#MUCRJQ|`lqHA5&{SyfB*srAb bool: - """Validate IRC nickname format""" - if not nick or len(nick) > 30: - return False - # RFC 2812 nickname pattern - pattern = r'^[a-zA-Z\[\]\\`_^{|}][a-zA-Z0-9\[\]\\`_^{|}\-]*$' - return bool(re.match(pattern, nick)) - - @staticmethod - def validate_channel(channel: str) -> bool: - """Validate IRC channel format""" - if not channel or len(channel) > 50: - return False - return channel.startswith('#') and ' ' not in channel - - @staticmethod - def validate_numeric_input(value: str, min_val: Optional[int] = None, max_val: Optional[int] = None) -> Optional[int]: - """Safely parse and validate numeric input""" - try: - num = int(value) - if min_val is not None and num < min_val: - return None - if max_val is not None and num > max_val: - return None - return num - except (ValueError, TypeError): - return None - - @staticmethod - def sanitize_message(message: str) -> str: - """Sanitize user input message""" - if not message: - return "" - # Remove control characters and limit length - sanitized = ''.join(char for char in message if ord(char) >= 32 or char in '\t\n') - return sanitized[:500] # Limit message length - -# Simple IRC message parser -def parse_message(line): - prefix = '' - trailing = '' - if line.startswith(':'): - prefix, line = line[1:].split(' ', 1) - if ' :' in line: - line, trailing = line.split(' :', 1) - parts = line.split() - command = parts[0] if parts else '' - params = parts[1:] if len(parts) > 1 else [] - return prefix, command, params, trailing - -class SimpleIRCBot: - def __init__(self, config): - self.config = config - self.logger = setup_logger() - self.reader: Optional[asyncio.StreamReader] = None - self.writer: Optional[asyncio.StreamWriter] = None - self.registered = False - self.channels_joined = set() - self.players = {} # Memory cache for speed - self.ducks = {} # Format: {channel: [{'alive': True, 'spawn_time': time, 'id': uuid}, ...]} - self.db_file = "duckhunt.json" - self.admins = [admin.lower() for admin in self.config.get('admins', ['colby'])] # Load from config only, case insensitive - self.ignored_nicks = set() # Nicks to ignore commands from - self.command_cooldowns = {} # Rate limiting for commands - self.duck_timeout_min = self.config.get('duck_timeout_min', 45) # Minimum duck timeout - self.duck_timeout_max = self.config.get('duck_timeout_max', 75) # Maximum duck timeout - self.duck_spawn_min = self.config.get('duck_spawn_min', 1800) # Minimum duck spawn time (30 min) - self.duck_spawn_max = self.config.get('duck_spawn_max', 5400) # Maximum duck spawn time (90 min) - self.shutdown_requested = False # Graceful shutdown flag - self.running_tasks = set() # Track running tasks for cleanup - - # Duck intelligence and records tracking - self.channel_records = {} # Channel-specific records {channel: {'fastest_shot': {}, 'last_duck': {}, 'total_ducks': 0}} - self.duck_difficulty = {} # Per-channel duck difficulty {channel: multiplier} - self.next_duck_spawn = {} # Track next spawn time per channel - - # Initialize SASL handler - self.sasl_handler = SASLHandler(self, config) - - # IRC Color codes - self.colors = { - 'red': '\x0304', - 'green': '\x0303', - 'blue': '\x0302', - 'yellow': '\x0308', - 'orange': '\x0307', - 'purple': '\x0306', - 'cyan': '\x0311', - 'white': '\x0300', - 'black': '\x0301', - 'gray': '\x0314', - 'reset': '\x03' - } - - # 40-level progression system with titles - self.levels = [ - {'xp': 0, 'title': 'Duck Harasser'}, - {'xp': 10, 'title': 'Unemployed'}, - {'xp': 25, 'title': 'Hunter Apprentice'}, - {'xp': 45, 'title': 'Duck Tracker'}, - {'xp': 70, 'title': 'Sharp Shooter'}, - {'xp': 100, 'title': 'Hunter'}, - {'xp': 135, 'title': 'Experienced Hunter'}, - {'xp': 175, 'title': 'Skilled Hunter'}, - {'xp': 220, 'title': 'Expert Hunter'}, - {'xp': 270, 'title': 'Master Hunter'}, - {'xp': 325, 'title': 'Duck Slayer'}, - {'xp': 385, 'title': 'Duck Terminator'}, - {'xp': 450, 'title': 'Duck Destroyer'}, - {'xp': 520, 'title': 'Duck Exterminator'}, - {'xp': 595, 'title': 'Duck Assassin'}, - {'xp': 675, 'title': 'Legendary Hunter'}, - {'xp': 760, 'title': 'Elite Hunter'}, - {'xp': 850, 'title': 'Supreme Hunter'}, - {'xp': 945, 'title': 'Ultimate Hunter'}, - {'xp': 1045, 'title': 'Godlike Hunter'}, - {'xp': 1150, 'title': 'Duck Nightmare'}, - {'xp': 1260, 'title': 'Duck Executioner'}, - {'xp': 1375, 'title': 'Duck Eliminator'}, - {'xp': 1495, 'title': 'Duck Obliterator'}, - {'xp': 1620, 'title': 'Duck Annihilator'}, - {'xp': 1750, 'title': 'Duck Devastator'}, - {'xp': 1885, 'title': 'Duck Vanquisher'}, - {'xp': 2025, 'title': 'Duck Conqueror'}, - {'xp': 2170, 'title': 'Duck Dominator'}, - {'xp': 2320, 'title': 'Duck Emperor'}, - {'xp': 2475, 'title': 'Duck Overlord'}, - {'xp': 2635, 'title': 'Duck Deity'}, - {'xp': 2800, 'title': 'Duck God'}, - {'xp': 2970, 'title': 'Duck Nemesis'}, - {'xp': 3145, 'title': 'Duck Apocalypse'}, - {'xp': 3325, 'title': 'Duck Armageddon'}, - {'xp': 3510, 'title': 'Duck Ragnarok'}, - {'xp': 3700, 'title': 'Duck Cataclysm'}, - {'xp': 3895, 'title': 'Duck Holocaust'}, - {'xp': 4095, 'title': 'Duck Genesis'} - ] - - # Sleep hours configuration (when ducks don't spawn) - self.sleep_hours = self.config.get('sleep_hours', []) # Format: [[22, 30], [8, 0]] for 22:30 to 08:00 - - # Duck planning system - self.daily_duck_plan = {} # Format: {channel: [(hour, minute), ...]} - - # Karma system - self.karma_events = ['teamkill', 'miss', 'wild_shot', 'hit', 'golden_hit'] - - self.load_database() - - def get_config(self, path, default=None): - """Get nested configuration value with fallback to default""" - keys = path.split('.') - value = self.config - for key in keys: - if isinstance(value, dict) and key in value: - value = value[key] - else: - return default - return value - - async def attempt_nickserv_auth(self): - """Delegate to SASL handler for NickServ auth""" - # For simple bot, we'll implement NickServ auth here - sasl_config = self.config.get('sasl', {}) - username = sasl_config.get('username', '') - password = sasl_config.get('password', '') - - if username and password: - self.logger.info(f"Attempting NickServ identification for {username}") - # Try both common NickServ commands - self.send_raw(f'PRIVMSG NickServ :IDENTIFY {username} {password}') - # Some networks use just the password if nick matches - await asyncio.sleep(1) - self.send_raw(f'PRIVMSG NickServ :IDENTIFY {password}') - self.logger.info("NickServ identification commands sent") - else: - self.logger.debug("No SASL credentials available for NickServ fallback") - - async def handle_nickserv_response(self, message): - """Handle responses from NickServ""" - message_lower = message.lower() - - if any(phrase in message_lower for phrase in [ - 'you are now identified', 'password accepted', 'you are already identified', - 'authentication successful', 'you have been identified' - ]): - self.logger.info("NickServ identification successful!") - - elif any(phrase in message_lower for phrase in [ - 'invalid password', 'incorrect password', 'access denied', - 'authentication failed', 'not registered', 'nickname is not registered' - ]): - self.logger.error(f"NickServ identification failed: {message}") - - else: - self.logger.debug(f"NickServ message: {message}") - - def get_player_level(self, xp): - """Get player level and title based on XP""" - for i in range(len(self.levels) - 1, -1, -1): - if xp >= self.levels[i]['xp']: - return i + 1, self.levels[i]['title'] - return 1, self.levels[0]['title'] - - def get_xp_for_next_level(self, xp): - """Get XP needed for next level""" - level, _ = self.get_player_level(xp) - if level < len(self.levels): - return self.levels[level]['xp'] - xp - return 0 # Max level reached - - def calculate_penalty_by_level(self, base_penalty, xp): - """Calculate penalty based on player level""" - level, _ = self.get_player_level(xp) - # Higher levels get higher penalties - return base_penalty + (level - 1) * 0.5 - - def update_karma(self, player, event): - """Update player karma based on event""" - if 'karma' not in player: - player['karma'] = 0 - - karma_changes = { - 'hit': 2, - 'golden_hit': 5, - 'teamkill': -10, - 'wild_shot': -3, - 'miss': -1 - } - - player['karma'] += karma_changes.get(event, 0) - - def is_sleep_time(self): - """Check if current time is within sleep hours""" - if not self.sleep_hours: - return False - - import datetime - now = datetime.datetime.now() - current_time = now.hour * 60 + now.minute - - for sleep_start, sleep_end in self.sleep_hours: - start_minutes = sleep_start[0] * 60 + sleep_start[1] - end_minutes = sleep_end[0] * 60 + sleep_end[1] - - if start_minutes <= end_minutes: # Same day - if start_minutes <= current_time <= end_minutes: - return True - else: # Crosses midnight - if current_time >= start_minutes or current_time <= end_minutes: - return True - return False - - def calculate_gun_reliability(self, player): - """Calculate gun reliability percentage""" - base_reliability = player.get('reliability', 70) - grease_bonus = 10 if player.get('grease', 0) > 0 else 0 - brush_bonus = 5 if player.get('brush', 0) > 0 else 0 - return min(base_reliability + grease_bonus + brush_bonus, 95) - - def gun_jams(self, player): - """Check if gun jams when firing""" - reliability = self.calculate_gun_reliability(player) - return random.randint(1, 100) > reliability - - async def scare_other_ducks(self, channel, shot_duck_id): - """Successful shots can scare other ducks away""" - if not self.config.get('successful_shots_scare_ducks', True): - return - - channel_ducks = self.ducks.get(channel, []) - for duck in channel_ducks: - if duck.get('alive') and duck['id'] != shot_duck_id: - # 30% chance to scare each remaining duck - if random.randint(1, 100) <= 30: - duck['scared'] = True - duck['alive'] = False - - async def scare_duck_on_miss(self, channel, target_duck): - """Duck may be scared by missed shots""" - if target_duck.get('hit_attempts', 0) >= 2: # Duck gets scared after 2+ attempts - if random.randint(1, 100) <= 40: # 40% chance to scare - target_duck['scared'] = True - target_duck['alive'] = False - self.send_message(channel, f"The duck got scared and flew away! (\\_o<) *flap flap*") - - async def find_bushes_items(self, nick, channel, player): - """Find items in bushes after killing a duck""" - if random.randint(1, 100) <= 12: # 12% chance to find something - found_items = [ - "Handful of sand", "Water bucket", "Four-leaf clover", "Mirror", - "Grease", "Brush for gun", "Spare clothes", "Sunglasses", - "Piece of bread", "Life insurance" - ] - found_item = random.choice(found_items) - - # Add item to player inventory - item_key = found_item.lower().replace(' ', '_').replace("'", "") - if 'four_leaf_clover' in item_key: - item_key = 'luck' - player['luck'] = player.get('luck', 0) + 1 - elif item_key in player: - player[item_key] = player.get(item_key, 0) + 1 - - self.send_message(channel, f"{nick} > {self.colors['cyan']}You found {found_item} in the bushes!{self.colors['reset']}") - self.save_player(f"{nick}!user@host") # Save player data - - def load_database(self): - """Load player data from JSON file""" - if os.path.exists(self.db_file): - try: - with open(self.db_file, 'r') as f: - data = json.load(f) - self.players = data.get('players', {}) - self.logger.info(f"Loaded {len(self.players)} players from {self.db_file}") - except (json.JSONDecodeError, IOError) as e: - self.logger.error(f"Error loading database: {e}") - self.players = {} - else: - self.players = {} - self.logger.info(f"Created new database: {self.db_file}") - - def save_database(self): - """Save all player data to JSON file with error handling""" - try: - # Atomic write to prevent corruption - temp_file = f"{self.db_file}.tmp" - data = { - 'players': self.players, - 'last_save': str(time.time()) - } - with open(temp_file, 'w') as f: - json.dump(data, f, indent=2) - - # Atomic rename to replace old file - import os - os.replace(temp_file, self.db_file) - - except IOError as e: - self.logger.error(f"Error saving database: {e}") - except Exception as e: - self.logger.error(f"Unexpected database save error: {e}") - - def is_admin(self, user): - """Check if user is admin by nick only""" - if '!' not in user: - return False - nick = user.split('!')[0].lower() - return nick in self.admins - - async def send_user_message(self, nick, channel, message, message_type='default'): - """Send message to user respecting their output mode preferences and config overrides""" - player = self.get_player(f"{nick}!*@*") - - # Check if this message type should be forced to public - force_public_key = f'message_output.force_public.{message_type}' - if self.get_config(force_public_key, False): - self.send_message(channel, message) - return - - # Default to config setting if player not found or no settings - default_mode = self.get_config('message_output.default_user_mode', 'PUBLIC') - output_mode = default_mode - if player and 'settings' in player: - output_mode = player['settings'].get('output_mode', default_mode) - # Handle legacy 'notices' setting for backwards compatibility - if 'output_mode' not in player['settings'] and 'notices' in player['settings']: - output_mode = 'NOTICE' if player['settings']['notices'] else 'PRIVMSG' - - if output_mode == 'PUBLIC': - # Send as regular channel message - self.send_message(channel, message) - elif output_mode == 'NOTICE': - # Send as notice to user - notice_msg = message.replace(f"{nick} > ", "") # Remove nick prefix for notice - self.send_raw(f'NOTICE {nick} :{notice_msg}') - else: # PRIVMSG - # Send as private message - private_msg = message.replace(f"{nick} > ", "") # Remove nick prefix for PM - self.send_message(nick, private_msg) - - def get_random_player_for_friendly_fire(self, shooter_nick): - """Get a random player (except shooter) for friendly fire""" - eligible_players = [] - shooter_lower = shooter_nick.lower() - - for nick in self.players.keys(): - if nick != shooter_lower: # Don't hit yourself - eligible_players.append(nick) - - if eligible_players: - return random.choice(eligible_players) - return None - - def _get_starting_accuracy(self): - """Get starting accuracy for new player - either fixed or random""" - if self.get_config('new_players.random_stats.enabled', False): - accuracy_range = self.get_config('new_players.random_stats.accuracy_range', [60, 80]) - if accuracy_range and len(accuracy_range) >= 2: - return random.randint(accuracy_range[0], accuracy_range[1]) - return self.get_config('new_players.starting_accuracy', 65) - - def _get_starting_reliability(self): - """Get starting reliability for new player - either fixed or random""" - if self.get_config('new_players.random_stats.enabled', False): - reliability_range = self.get_config('new_players.random_stats.reliability_range', [65, 85]) - if reliability_range and len(reliability_range) >= 2: - return random.randint(reliability_range[0], reliability_range[1]) - return self.get_config('new_players.starting_reliability', 70) - - async def auto_rearm_confiscated_guns(self, channel, shooter_nick): - """Auto-rearm all players with confiscated guns when someone shoots a duck""" - if not self.get_config('weapons.auto_rearm_on_duck_shot', False): - return - - rearmed_players = [] - for user_host, player_data in self.players.items(): - if player_data.get('gun_confiscated', False): - player_data['gun_confiscated'] = False - player_data['ammo'] = player_data.get('ammo', 0) + 1 # Give them 1 ammo - - # Get just the nickname from user!host format - nick = user_host.split('!')[0] if '!' in user_host else user_host - rearmed_players.append(nick) - - if rearmed_players: - self.save_database() - # Send notification to channel - rearmed_list = ', '.join(rearmed_players) - self.send_message(channel, f"🔫 {self.colors['green']}Auto-rearm:{self.colors['reset']} {rearmed_list} got their guns back! (Thanks to {shooter_nick}'s duck shot)") - self.logger.info(f"Auto-rearmed {len(rearmed_players)} players after {shooter_nick} shot duck in {channel}") - - async def update_channel_records(self, channel, hunter, shot_time, duck_type): - """Update channel records and duck difficulty after a successful shot""" - if not self.get_config('records_tracking.enabled', True): - return - - # Initialize channel records if needed - if channel not in self.channel_records: - self.channel_records[channel] = { - 'fastest_shot': None, - 'last_duck': None, - 'total_ducks': 0, - 'total_shots': 0 - } - - records = self.channel_records[channel] - - # Update totals - records['total_ducks'] += 1 - - # Update fastest shot record - if not records['fastest_shot'] or shot_time < records['fastest_shot']['time']: - records['fastest_shot'] = { - 'time': shot_time, - 'hunter': hunter, - 'duck_type': duck_type, - 'timestamp': time.time() - } - # Announce new record - self.send_message(channel, f"🏆 {self.colors['yellow']}NEW RECORD!{self.colors['reset']} {hunter} set fastest shot: {shot_time:.3f}s!") - - # Update last duck info - records['last_duck'] = { - 'hunter': hunter, - 'type': duck_type, - 'shot_time': shot_time, - 'timestamp': time.time() - } - - # Increase duck difficulty (smartness) - if self.get_config('duck_smartness.enabled', True): - if channel not in self.duck_difficulty: - self.duck_difficulty[channel] = 1.0 - - learning_rate = self.get_config('duck_smartness.learning_rate', 0.1) - max_difficulty = self.get_config('duck_smartness.max_difficulty_multiplier', 2.0) - - # Ensure max_difficulty has a valid value - if max_difficulty is None: - max_difficulty = 2.0 - - # Increase difficulty slightly with each duck shot - self.duck_difficulty[channel] = min( - max_difficulty, - self.duck_difficulty[channel] + learning_rate - ) - - # Save records to database periodically - self.save_database() - - async def connect(self): - try: - server = self.config['server'] - port = self.config['port'] - ssl_context = ssl.create_default_context() if self.config.get('ssl', True) else None - - self.logger.info(f"Connecting to {server}:{port} (SSL: {ssl_context is not None})") - - self.reader, self.writer = await asyncio.open_connection( - server, port, ssl=ssl_context - ) - self.logger.info("Connected successfully!") - - # Start SASL negotiation if enabled - if await self.sasl_handler.start_negotiation(): - return True - else: - # Standard registration without SASL - await self.register_user() - return True - except Exception as e: - self.logger.error(f"Connection failed: {e}") - return False - - async def register_user(self): - """Register the user with the IRC server""" - # Send password FIRST if configured (for I-line exemption) - if self.config.get('password'): - self.send_raw(f'PASS {self.config["password"]}') - - self.logger.info(f"Registering as {self.config['nick']}") - self.send_raw(f'NICK {self.config["nick"]}') - self.send_raw(f'USER {self.config["nick"]} 0 * :DuckHunt Bot') - - def send_raw(self, msg): - # Skip debug logging for speed - # self.logger.debug(f"-> {msg}") - if self.writer: - self.writer.write((msg + '\r\n').encode()) - - def send_message(self, target, msg): - # Skip logging during gameplay for speed (uncomment for debugging) - # self.logger.info(f"Sending to {target}: {msg}") - self.send_raw(f'PRIVMSG {target} :{msg}') - # Remove drain() for faster responses - let TCP handle buffering - - def check_rate_limit(self, nick: str, channel: str) -> bool: - """Check if user is within rate limits""" - try: - current_time = time.time() - key = f"{nick}:{channel}" - - # Rate limit: 5 commands per 30 seconds per user per channel - if key not in self.command_cooldowns: - self.command_cooldowns[key] = [] - - # Remove old entries - self.command_cooldowns[key] = [ - timestamp for timestamp in self.command_cooldowns[key] - if current_time - timestamp < 30 - ] - - # Check if under limit - if len(self.command_cooldowns[key]) >= 5: - return False - - # Add current command - self.command_cooldowns[key].append(current_time) - return True - - except Exception as e: - self.logger.error(f"Rate limit check failed: {e}") - return True # Allow command if rate limiting fails - - def get_player(self, user): - """Get player data by nickname only (case insensitive)""" - if '!' not in user: - return None - - nick = user.split('!')[0].lower() # Case insensitive - - # Use nick as database key - if nick in self.players: - player = self.players[nick] - # Backward compatibility: ensure all required fields exist - if 'missed' not in player: - player['missed'] = 0 - if 'inventory' not in player: - player['inventory'] = {} - return player - - # Create new player with configurable defaults - player_data = { - 'xp': self.get_config('new_players.starting_xp', 0), - 'caught': 0, - 'befriended': 0, # Separate counter for befriended ducks - 'missed': 0, - 'ammo': self.get_config('weapons.starting_ammo', 6), - 'max_ammo': self.get_config('weapons.max_ammo_base', 6), - 'chargers': self.get_config('weapons.starting_chargers', 2), - 'max_chargers': self.get_config('weapons.max_chargers_base', 2), - 'accuracy': self._get_starting_accuracy(), - 'reliability': self._get_starting_reliability(), - 'weapon': self.get_config('weapons.starting_weapon', 'pistol'), - 'gun_confiscated': False, - 'explosive_ammo': False, - 'settings': { - 'output_mode': self.get_config('message_output.default_user_mode', 'PUBLIC'), - 'notices': True, # Legacy setting for backwards compatibility - 'private_messages': False - }, - # Inventory system - 'inventory': {}, - # New advanced stats - 'golden_ducks': 0, - 'karma': self.get_config('new_players.starting_karma', 0), - 'deflection': self.get_config('new_players.starting_deflection', 0), - 'defense': self.get_config('new_players.starting_defense', 0), - 'jammed': False, - 'jammed_count': 0, - 'deaths': 0, - 'neutralized': 0, - 'deflected': 0, - 'best_time': 999.9, - 'total_reflex_time': 0.0, - 'reflex_shots': 0, - 'wild_shots': 0, - 'accidents': 0, - 'total_ammo_used': 0, - 'shot_at': 0, - 'lucky_shots': 0, - # Shop items - 'luck': 0, - 'detector': 0, - 'silencer': 0, - 'sunglasses': 0, - 'clothes': 0, - 'grease': 0, - 'brush': 0, - 'mirror': 0, - 'sand': 0, - 'water': 0, - 'sabotage': 0, - 'life_insurance': 0, - 'liability': 0, - 'decoy': 0, - 'bread': 0, - 'duck_detector': 0, - 'mechanical': 0 - } - - self.players[nick] = player_data - self.save_database() # Auto-save new players - return player_data - - def save_player(self, user): - """Save player data - batch saves for performance""" - if not hasattr(self, '_save_pending'): - self._save_pending = False - - if not self._save_pending: - self._save_pending = True - # Schedule delayed save to batch multiple writes - asyncio.create_task(self._delayed_save()) - - async def _delayed_save(self): - """Batch save to reduce disk I/O""" - await asyncio.sleep(0.5) # Small delay to batch saves - try: - self.save_database() - self.logger.debug("Database batch save completed") - except Exception as e: - self.logger.error(f"Database batch save failed: {e}") - finally: - self._save_pending = False - - def setup_signal_handlers(self): - """Setup signal handlers for graceful shutdown""" - def signal_handler(signum): - signal_name = signal.Signals(signum).name - self.logger.info(f"Received {signal_name}, initiating graceful shutdown...") - self.shutdown_requested = True - - # Handle common shutdown signals - if hasattr(signal, 'SIGTERM'): - signal.signal(signal.SIGTERM, lambda s, f: signal_handler(s)) - if hasattr(signal, 'SIGINT'): - signal.signal(signal.SIGINT, lambda s, f: signal_handler(s)) - if hasattr(signal, 'SIGHUP'): - signal.signal(signal.SIGHUP, lambda s, f: signal_handler(s)) - - def is_rate_limited(self, user, command, cooldown=2.0): - """Check if user is rate limited for a command""" - now = time.time() - key = f"{user}:{command}" - - if key in self.command_cooldowns: - if now - self.command_cooldowns[key] < cooldown: - return True - - self.command_cooldowns[key] = now - return False - - async def handle_command(self, user, channel, message): - """Enhanced command handler with logging, validation, and graceful degradation""" - if not user: - self.logger.warning("Received command with no user information") - return - - try: - nick = user.split('!')[0] - nick_lower = nick.lower() - - # Input validation - if not InputValidator.validate_nickname(nick): - self.logger.warning(f"Invalid nickname format: {nick}") - return - - if not InputValidator.validate_channel(channel) and not channel == self.config['nick']: - self.logger.warning(f"Invalid channel format: {channel}") - return - - # Sanitize message input - message = InputValidator.sanitize_message(message) - if not message: - return - - # Enhanced logging with context - self.logger.debug(f"Processing command from {nick} in {channel}: {message[:100]}") - - # Check if user is ignored - if nick_lower in self.ignored_nicks: - self.logger.debug(f"Ignoring command from ignored user: {nick}") - return - - # Rate limiting check - if not self.check_rate_limit(nick_lower, channel): - self.logger.info(f"Rate limit exceeded for {nick} in {channel}") - return - - # Determine if this is a private message to the bot - is_private = channel == self.config['nick'] - - # For private messages, use the nick as the target for responses - response_target = nick if is_private else channel - - # Handle private messages (no ! prefix needed) - if is_private: - cmd = message.strip().lower() - self.logger.info(f"Private command from {nick}: {cmd}") - - # Private message admin commands - if self.is_admin(user): - if cmd == 'restart': - await self.handle_restart(nick, response_target) - return - elif cmd == 'quit': - await self.handle_quit(nick, response_target) - return - elif cmd == 'launch' or cmd == 'ducklaunch': - # For private messages, launch in all channels - for chan in self.channels_joined: - await self.spawn_duck_now(chan) - self.send_message(response_target, f"{nick} > Launched ducks in all channels!") - return - elif cmd == 'golden' or cmd == 'goldenduck': - # Launch golden ducks - for chan in self.channels_joined: - await self.spawn_duck_now(chan, force_golden=True) - self.send_message(response_target, f"{nick} > Launched {self.colors['yellow']}GOLDEN DUCKS{self.colors['reset']} in all channels!") - return - elif cmd.startswith('ignore '): - target_nick = cmd[7:].strip().lower() - await self.handle_ignore(nick, response_target, target_nick) - return - elif cmd.startswith('delignore '): - target_nick = cmd[10:].strip().lower() - await self.handle_delignore(nick, response_target, target_nick) - return - else: - # Unknown private command - self.send_message(response_target, f"{nick} > Admin commands: restart, quit, launch, golden, ignore , delignore ") - return - else: - # Non-admin private message - self.send_message(response_target, f"{nick} > Private commands are admin-only. Use !help in a channel for game commands.") - return - - # Handle channel messages (must start with !) - if not message.startswith('!'): - return - - # Extract just the command part (first word) to handle emojis and extra text - cmd = message.strip().lower().split()[0] - # Keep the original message for commands that need arguments - full_cmd = message.strip().lower() - - # Regular game commands (channel only) - # Inline common commands for speed - if cmd == '!bang': - # Rate limit shooting to prevent spam - if self.is_rate_limited(user, 'bang', 1.0): - return - - player = self.get_player(user) - if not player: - return - - # Check if gun is confiscated - if player.get('gun_confiscated', False): - self.send_message(channel, f"{nick} > {self.colors['red']}Your gun has been confiscated! Buy a new gun from the shop (item #5).{self.colors['reset']}") - return - - # Check if gun is jammed - if player.get('jammed', False): - self.send_message(channel, f"{nick} > {self.colors['red']}Your gun is jammed! Use !reload to unjam it.{self.colors['reset']}") - return - - # Check ammo - if player['ammo'] <= 0: - self.send_message(channel, f"{nick} > Your gun is empty! | Ammo: 0/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") - return - - # Check for gun jamming before shooting - if self.gun_jams(player): - player['jammed'] = True - player['jammed_count'] = player.get('jammed_count', 0) + 1 - jam_sound = "•click• •click•" if player.get('silencer', 0) > 0 else "*CLICK* *CLICK*" - self.send_message(channel, f"{nick} > {jam_sound} Your gun jammed! Use !reload to unjam it.") - self.save_player(user) - return - - # Get ducks in this channel - channel_ducks = self.ducks.get(channel, []) - alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] - - # Consume ammo - player['ammo'] -= 1 - player['total_ammo_used'] = player.get('total_ammo_used', 0) + 1 - - if alive_ducks: - # Target the oldest duck (first in, first out) - target_duck = alive_ducks[0] - shot_time = time.time() - target_duck['spawn_time'] - is_golden = target_duck.get('type') == 'golden' - - # Calculate hit chance (golden ducks are harder to hit) - base_accuracy = player['accuracy'] - if is_golden: - base_accuracy = max(base_accuracy - 30, 10) # Golden ducks much harder - - # Apply bonuses - if player.get('sunglasses', 0) > 0: - base_accuracy += 5 # Sunglasses help - if player.get('mirror', 0) > 0: - base_accuracy += 3 # Mirror helps - - # Apply duck smartness penalty - duck_difficulty = self.duck_difficulty.get(channel, 1.0) - if duck_difficulty > 1.0: - # Smarter ducks are harder to hit - difficulty_penalty = (duck_difficulty - 1.0) * 20 # Up to 20% penalty at max difficulty - base_accuracy = max(base_accuracy - difficulty_penalty, 10) # Never go below 10% - - hit_chance = min(base_accuracy, 95) # Cap at 95% - - # Record shot attempt - player['shot_at'] = player.get('shot_at', 0) + 1 - target_duck['hit_attempts'] = target_duck.get('hit_attempts', 0) + 1 - - # Track total shots for channel statistics - if channel not in self.channel_records: - self.channel_records[channel] = {'total_shots': 0, 'total_ducks': 0} - self.channel_records[channel]['total_shots'] += 1 - - # Check for hit - if random.randint(1, 100) <= hit_chance: - # HIT! - player['caught'] += 1 - target_duck['alive'] = False - - # Update reflex time stats - player['reflex_shots'] = player.get('reflex_shots', 0) + 1 - player['total_reflex_time'] = player.get('total_reflex_time', 0) + shot_time - if shot_time < player.get('best_time', 999.9): - player['best_time'] = shot_time - - # Calculate XP and rewards - if is_golden: - player['golden_ducks'] = player.get('golden_ducks', 0) + 1 - base_xp = 50 # Golden ducks give much more XP - self.update_karma(player, 'golden_hit') - else: - base_xp = 15 # Normal XP - self.update_karma(player, 'hit') - - # Lucky shot bonus - luck_multiplier = 1 + (player.get('luck', 0) * 0.1) # 10% per luck point - is_lucky = random.randint(1, 100) <= (5 + player.get('luck', 0)) - if is_lucky: - player['lucky_shots'] = player.get('lucky_shots', 0) + 1 - luck_multiplier *= 1.5 # 50% bonus for lucky shot - - xp_earned = int(base_xp * luck_multiplier) - player['xp'] += xp_earned - - # Sound effects based on ammo type - if player.get('explosive_ammo', False): - shot_sound = "•BOUM•" if player.get('silencer', 0) > 0 else "*BOUM*" - explosive_text = f" {self.colors['orange']}[explosive ammo]{self.colors['reset']}" - else: - shot_sound = "•bang•" if player.get('silencer', 0) > 0 else "*BANG*" - explosive_text = "" - - # Lucky shot text - lucky_text = f" {self.colors['yellow']}[lucky shot!]{self.colors['reset']}" if is_lucky else "" - - # Build hit message - level, title = self.get_player_level(player['xp']) - - if is_golden: - golden_count = player.get('golden_ducks', 0) - hit_msg = f"{nick} > {self.colors['yellow']}{shot_sound}{self.colors['reset']} You shot down the {self.colors['yellow']}★ GOLDEN DUCK ★{self.colors['reset']} in {shot_time:.3f}s! Total: {player['caught']} ducks ({self.colors['yellow']}{golden_count} golden{self.colors['reset']}) | Level {level}: {title} | [{self.colors['yellow']}{xp_earned} xp{self.colors['reset']}]{explosive_text}{lucky_text}" - else: - hit_msg = f"{nick} > {self.colors['green']}{shot_sound}{self.colors['reset']} You shot down the duck in {shot_time:.3f}s! Total: {player['caught']} ducks | Level {level}: {title} | [{self.colors['green']}{xp_earned} xp{self.colors['reset']}]{explosive_text}{lucky_text}" - - self.send_message(channel, hit_msg) - - # Scare other ducks if enabled (successful shots can scare ducks) - await self.scare_other_ducks(channel, target_duck['id']) - - # Find items in bushes (rare chance) - await self.find_bushes_items(nick, channel, player) - - # Auto-rearm confiscated guns if enabled - await self.auto_rearm_confiscated_guns(channel, nick) - - # Track records and increase duck difficulty - await self.update_channel_records(channel, nick, shot_time, target_duck['type']) - - else: - # MISS! - player['missed'] += 1 - self.update_karma(player, 'miss') - - # Calculate miss penalty based on level - miss_penalty = int(self.calculate_penalty_by_level(-2, player['xp'])) - player['xp'] += miss_penalty - - # Bullet ricochet chance (can hit other players) - ricochet_chance = 8 # 8% base chance - if player.get('explosive_ammo', False): - ricochet_chance = 15 # Higher with explosive - - ricochet_msg = "" - if random.randint(1, 100) <= ricochet_chance: - ricochet_target = self.get_random_player_for_friendly_fire(nick) - if ricochet_target: - target_player = self.players[ricochet_target] - ricochet_dmg = -3 - target_player['xp'] += ricochet_dmg - target_player['shot_at'] = target_player.get('shot_at', 0) + 1 - ricochet_msg = f" {self.colors['red']}[RICOCHET: {ricochet_target} hit for {ricochet_dmg} xp]{self.colors['reset']}" - - # Scare duck on miss - await self.scare_duck_on_miss(channel, target_duck) - - miss_sound = "•click•" if player.get('silencer', 0) > 0 else "*CLICK*" - await self.send_user_message(nick, channel, f"{nick} > {miss_sound} You missed the duck! [miss: {miss_penalty} xp]{ricochet_msg}") - - else: - # No duck present - wild fire! - player['wild_shots'] = player.get('wild_shots', 0) + 1 - self.update_karma(player, 'wild_shot') - - # Track wild shots in channel statistics - if channel not in self.channel_records: - self.channel_records[channel] = {'total_shots': 0, 'total_ducks': 0} - self.channel_records[channel]['total_shots'] += 1 - - # Calculate penalties based on level - miss_penalty = int(self.calculate_penalty_by_level(-2, player['xp'])) - wild_penalty = int(self.calculate_penalty_by_level(-3, player['xp'])) - player['xp'] += miss_penalty + wild_penalty - - # Confiscate gun - player['gun_confiscated'] = True - - # Higher chance of hitting other players when no duck - friendly_fire_chance = 25 # 25% when no duck - friendly_fire_msg = "" - - if random.randint(1, 100) <= friendly_fire_chance: - ff_target = self.get_random_player_for_friendly_fire(nick) - if ff_target: - target_player = self.players[ff_target] - ff_dmg = int(self.calculate_penalty_by_level(-4, target_player['xp'])) - target_player['xp'] += ff_dmg - target_player['shot_at'] = target_player.get('shot_at', 0) + 1 - player['accidents'] = player.get('accidents', 0) + 1 - self.update_karma(player, 'teamkill') - friendly_fire_msg = f" {self.colors['red']}[ACCIDENT: {ff_target} injured for {ff_dmg} xp]{self.colors['reset']}" - - wild_sound = "•BOUM•" if player.get('explosive_ammo', False) else "*BANG*" - if player.get('silencer', 0) > 0: - wild_sound = "•" + wild_sound[1:-1] + "•" - - confiscated_msg = f" {self.colors['red']}[GUN CONFISCATED]{self.colors['reset']}" - await self.send_user_message(nick, channel, f"{nick} > {wild_sound} You shot at nothing! What were you aiming at? [miss: {miss_penalty} xp] [wild fire: {wild_penalty} xp]{confiscated_msg}{friendly_fire_msg}") - - # Save after each shot - self.save_player(user) - - elif cmd == '!bef': - # Check if befriending is enabled - if not self.get_config('befriending.enabled', True): - self.send_message(channel, f"{nick} > Duck befriending is currently disabled!") - return - - player = self.get_player(user) - if not player: - return - - # Get ducks in this channel - channel_ducks = self.ducks.get(channel, []) - alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] - - if alive_ducks: - # Target the oldest duck (first in, first out) - target_duck = alive_ducks[0] - bef_time = time.time() - target_duck['spawn_time'] - - # Calculate befriend success chance using config values - level, _ = self.get_player_level(player['xp']) - base_success = self.get_config('befriending.base_success_rate', 65) or 65 - max_success = self.get_config('befriending.max_success_rate', 90) or 90 - level_bonus_per_level = self.get_config('befriending.level_bonus_per_level', 2) or 2 - level_bonus_cap = self.get_config('befriending.level_bonus_cap', 20) or 20 - luck_bonus_per_point = self.get_config('befriending.luck_bonus_per_point', 3) or 3 - - level_bonus = min(level * level_bonus_per_level, level_bonus_cap) - luck_bonus = player.get('luck', 0) * luck_bonus_per_point - success_chance = min(base_success + level_bonus + luck_bonus, max_success) - - # Check if befriend attempt succeeds - if random.randint(1, 100) <= success_chance: - # Successful befriend - player['befriended'] = player.get('befriended', 0) + 1 - - # XP rewards from config - xp_min = self.get_config('befriending.xp_reward_min', 1) or 1 - xp_max = self.get_config('befriending.xp_reward_max', 3) or 3 - - xp_earned = random.randint(xp_min, xp_max) - player['xp'] += xp_earned - - # Mark duck as befriended (dead) - target_duck['alive'] = False - - # Lucky items with configurable chance - if self.get_config('items.lucky_items_enabled', True): - lucky_items = ["four-leaf clover", "rabbit's foot", "horseshoe", "lucky penny", "magic feather"] - base_luck_chance = self.get_config('befriending.lucky_item_chance', 5) + player.get('luck', 0) - lucky_item = random.choice(lucky_items) if random.randint(1, 100) <= base_luck_chance else None - lucky_text = f" [{lucky_item}]" if lucky_item else "" - else: - lucky_text = "" - - remaining_ducks = len([d for d in channel_ducks if d.get('alive')]) - duck_count_text = f" | {remaining_ducks} ducks remain" if remaining_ducks > 0 else "" - - self.send_message(channel, f"{nick} > You befriended a duck in {bef_time:.3f}s! Total friends: {player['befriended']} ducks on {channel}. \\_o< *quack* [+{xp_earned} xp]{lucky_text}{duck_count_text}") - - # Update karma for successful befriend - if self.get_config('karma.enabled', True): - karma_bonus = self.get_config('karma.befriend_success_bonus', 2) - player['karma'] = player.get('karma', 0) + karma_bonus - - # Save to database after befriending - self.save_player(user) - else: - # Duck refuses to be befriended - refusal_messages = [ - f"{nick} > The duck looks at you suspiciously and waddles away! \\_o< *suspicious quack*", - f"{nick} > The duck refuses to be friends and flaps away angrily! \\_O< *angry quack*", - f"{nick} > The duck ignores your friendship attempts and goes back to swimming! \\_o< *indifferent quack*", - f"{nick} > The duck seems shy and hides behind some reeds! \\_o< *shy quack*", - f"{nick} > The duck is too busy looking for food to be your friend! \\_o< *hungry quack*", - f"{nick} > The duck gives you a cold stare and swims to the other side! \\_O< *cold quack*", - f"{nick} > The duck prefers to stay wild and free! \\_o< *wild quack*", - f"{nick} > The duck thinks you're trying too hard and keeps its distance! \\_o< *skeptical quack*" - ] - - # Small chance the duck gets scared and flies away (configurable) - scared_chance = self.get_config('befriending.scared_away_chance', 10) or 10 - if random.randint(1, 100) <= scared_chance: - target_duck['alive'] = False - scared_messages = [ - f"{nick} > Your friendship attempt scared the duck away! It flies off into the sunset! \\_o< *departing quack*", - f"{nick} > The duck panics at your approach and escapes! \\_O< *panicked quack* *flap flap*" - ] - self.send_message(channel, random.choice(scared_messages)) - else: - self.send_message(channel, random.choice(refusal_messages)) - - # XP penalty for failed befriend attempt (configurable) - xp_penalty = self.get_config('befriending.failure_xp_penalty', 1) - player['xp'] = max(0, player['xp'] - xp_penalty) - - # Update karma for failed befriend - if self.get_config('karma.enabled', True): - karma_penalty = self.get_config('karma.befriend_fail_penalty', 1) - player['karma'] = player.get('karma', 0) - karma_penalty - - # Save player data - self.save_player(user) - else: - self.send_message(channel, f"{nick} > There is no duck to befriend!") - - elif cmd == '!reload': - player = self.get_player(user) - if not player: - return - - # Check if gun is jammed (reload unjams it) - if player.get('jammed', False): - player['jammed'] = False - unjam_sound = "•click click•" if player.get('silencer', 0) > 0 else "*click click*" - self.send_message(channel, f"{nick} > {unjam_sound} You unjammed your gun! | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") - self.save_player(user) - return - - if player['ammo'] == player['max_ammo']: - self.send_message(channel, f"{nick} > Your gun doesn't need to be reloaded. | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") - return - - if player['chargers'] <= 0: - self.send_message(channel, f"{nick} > You don't have any chargers left! | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: 0/{player['max_chargers']}") - return - - # Calculate reload reliability - reload_reliability = self.calculate_gun_reliability(player) - - if random.randint(1, 100) <= reload_reliability: - player['chargers'] -= 1 - player['ammo'] = player['max_ammo'] - reload_sound = "•click•" if player.get('silencer', 0) > 0 else "*click*" - self.send_message(channel, f"{nick} > {reload_sound} You reloaded your gun! | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}") - else: - # Gun jams during reload - player['jammed'] = True - player['jammed_count'] = player.get('jammed_count', 0) + 1 - jam_sound = "•CLACK• •click click•" if player.get('silencer', 0) > 0 else "*CLACK* *click click*" - self.send_message(channel, f"{nick} > {jam_sound} Your gun jammed while reloading! Use !reload again to unjam it.") - - # Save to database after reload - self.save_player(user) - - elif cmd == '!stats': - await self.handle_stats(nick, channel, user) - elif cmd == '!help': - await self.handle_help(nick, channel) - elif full_cmd.startswith('!shop'): - # Handle !shop or !shop - parts = full_cmd.split() - if len(parts) == 1: - # Just !shop - show shop listing - await self.handle_shop(nick, channel, user) - elif len(parts) >= 2: - # !shop - purchase item - item_id = parts[1] - await self.handle_buy(nick, channel, item_id, user) - elif full_cmd.startswith('!use '): - parts = full_cmd[5:].split() - if len(parts) >= 1: - item_id = parts[0] - target_nick = parts[1] if len(parts) >= 2 else None - await self.handle_use(nick, channel, item_id, user, target_nick) - else: - self.send_message(channel, f"{nick} > Usage: !use [target_nick]") - elif full_cmd.startswith('!sell '): - item_id = full_cmd[6:].strip() - await self.handle_sell(nick, channel, item_id, user) - elif full_cmd.startswith('!trade '): - parts = full_cmd[7:].split() - if len(parts) >= 3: - target_nick, item, amount = parts[0], parts[1], parts[2] - await self.handle_trade(nick, channel, user, target_nick, item, amount) - else: - self.send_message(channel, f"{nick} > Usage: !trade ") - elif full_cmd.startswith('!rearm ') and self.is_admin(user): # Admin only - # Allow rearming other players or self - target_nick = full_cmd[7:].strip() - await self.handle_rearm(nick, channel, user, target_nick) - elif cmd == '!rearm' and self.is_admin(user): # Admin only - # Rearm self - await self.handle_rearm(nick, channel, user, nick) - elif cmd == '!duck' and self.is_admin(user): # Admin only - await self.spawn_duck_now(channel) - elif cmd == '!golden' and self.is_admin(user): # Admin only - await self.spawn_duck_now(channel, force_golden=True) - elif cmd == '!listplayers' and self.is_admin(user): # Admin only - await self.handle_listplayers(nick, channel) - elif full_cmd.startswith('!ban ') and self.is_admin(user): # Admin only - target_nick = full_cmd[5:].strip() - await self.handle_ban(nick, channel, target_nick) - elif full_cmd.startswith('!reset ') and self.is_admin(user): # Admin only - target_nick = full_cmd[7:].strip() - await self.handle_reset(nick, channel, target_nick) - elif cmd == '!resetdb' and self.is_admin(user): # Admin only - await self.handle_reset_database(nick, channel, user) - elif full_cmd.startswith('!resetdb confirm ') and self.is_admin(user): # Admin only - confirmation = full_cmd[17:].strip() - await self.handle_reset_database_confirm(nick, channel, user, confirmation) - elif cmd == '!restart' and self.is_admin(user): # Admin only - await self.handle_restart(nick, channel) - elif cmd == '!quit' and self.is_admin(user): # Admin only - await self.handle_quit(nick, channel) - elif cmd == '!ducklaunch' and self.is_admin(user): # Admin only - await self.spawn_duck_now(channel) - elif cmd == '!ducks': - # Show duck count for all users - channel_ducks = self.ducks.get(channel, []) - alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] - dead_ducks = [duck for duck in channel_ducks if not duck.get('alive')] - - if alive_ducks: - duck_list = [] - for duck in alive_ducks: - duck_type = duck.get('type', 'normal') - spawn_time = time.time() - duck['spawn_time'] - if duck_type == 'golden': - duck_list.append(f"{self.colors['yellow']}Golden Duck{self.colors['reset']} ({spawn_time:.1f}s)") - else: - duck_list.append(f"Duck ({spawn_time:.1f}s)") - self.send_message(channel, f"{nick} > Active ducks: {', '.join(duck_list)}") - else: - self.send_message(channel, f"{nick} > No ducks currently active.") - - elif cmd == '!top' or cmd == '!leaderboard': - # Show top players by XP - if not self.players: - self.send_message(channel, f"{nick} > No players found!") - return - - # Sort players by XP - sorted_players = sorted(self.players.items(), key=lambda x: x[1]['xp'], reverse=True) - top_5 = sorted_players[:5] - - self.send_message(channel, f"{self.colors['cyan']}🏆 TOP HUNTERS LEADERBOARD 🏆{self.colors['reset']}") - for i, (player_nick, player_data) in enumerate(top_5, 1): - level, title = self.get_player_level(player_data['xp']) - total_ducks = player_data.get('caught', 0) + player_data.get('befriended', 0) - golden = player_data.get('golden_ducks', 0) - golden_text = f" ({self.colors['yellow']}{golden} golden{self.colors['reset']})" if golden > 0 else "" - - if i == 1: - rank_color = self.colors['yellow'] # Gold - elif i == 2: - rank_color = self.colors['gray'] # Silver - elif i == 3: - rank_color = self.colors['orange'] # Bronze - else: - rank_color = self.colors['white'] - - self.send_message(channel, f"{rank_color}#{i}{self.colors['reset']} {player_nick} - Level {level}: {title} | XP: {player_data['xp']} | Ducks: {total_ducks}{golden_text}") - - elif cmd == '!levels': - # Show level progression table - self.send_message(channel, f"{self.colors['cyan']}🎯 LEVEL PROGRESSION SYSTEM 🎯{self.colors['reset']}") - - # Show first 10 levels as example - for i in range(min(10, len(self.levels))): - level_data = self.levels[i] - next_xp = self.levels[i + 1]['xp'] if i + 1 < len(self.levels) else "MAX" - self.send_message(channel, f"Level {i + 1}: {level_data['title']} (XP: {level_data['xp']} - {next_xp})") - - if len(self.levels) > 10: - self.send_message(channel, f"... and {len(self.levels) - 10} more levels up to Level {len(self.levels)}: {self.levels[-1]['title']}") - - elif full_cmd.startswith('!level '): - # Show specific player's level info - target_nick = full_cmd[7:].strip().lower() - if target_nick in self.players: - target_player = self.players[target_nick] - level, title = self.get_player_level(target_player['xp']) - xp_for_next = self.get_xp_for_next_level(target_player['xp']) - - if xp_for_next > 0: - next_info = f"Next level in {xp_for_next} XP" - else: - next_info = "MAX LEVEL REACHED!" - - self.send_message(channel, f"{nick} > {target_nick}: Level {level} - {self.colors['cyan']}{title}{self.colors['reset']} | {next_info}") - else: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - - elif cmd == '!karma': - # Show karma leaderboard - if not self.players: - self.send_message(channel, f"{nick} > No players found!") - return - - # Sort by karma - karma_players = [(nick, data) for nick, data in self.players.items() if data.get('karma', 0) != 0] - karma_players.sort(key=lambda x: x[1].get('karma', 0), reverse=True) - - if not karma_players: - self.send_message(channel, f"{nick} > No karma data available!") - return - - self.send_message(channel, f"{self.colors['purple']}☯ KARMA LEADERBOARD ☯{self.colors['reset']}") - for i, (player_nick, player_data) in enumerate(karma_players[:5], 1): - karma = player_data.get('karma', 0) - karma_color = self.colors['green'] if karma >= 0 else self.colors['red'] - karma_text = "Saint" if karma >= 50 else "Good" if karma >= 10 else "Evil" if karma <= -10 else "Chaotic" if karma <= -50 else "Neutral" - - self.send_message(channel, f"#{i} {player_nick} - {karma_color}Karma: {karma}{self.colors['reset']} ({karma_text})") - - elif cmd == '!ducks': - # Show duck count for all users - channel_ducks = self.ducks.get(channel, []) - alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] - dead_ducks = [duck for duck in channel_ducks if not duck.get('alive')] - - if alive_ducks: - oldest_time = min(time.time() - duck['spawn_time'] for duck in alive_ducks) - self.send_message(channel, f"{nick} > {len(alive_ducks)} ducks in {channel} | Oldest: {oldest_time:.1f}s | Dead: {len(dead_ducks)} | Timeout: {self.duck_timeout_min}-{self.duck_timeout_max}s") - else: - self.send_message(channel, f"{nick} > No ducks in {channel} | Dead: {len(dead_ducks)}") - elif cmd == '!output' or full_cmd.startswith('!output '): - parts = cmd.split(maxsplit=1) - output_type = parts[1] if len(parts) > 1 else '' - await self.handle_output(nick, channel, user, output_type) - - elif cmd == '!ducktime': - # Show time until next duck spawn - await self.handle_ducktime(nick, channel) - - elif cmd == '!lastduck': - # Show information about the last duck shot - await self.handle_lastduck(nick, channel) - - elif cmd == '!records': - # Show channel records - await self.handle_records(nick, channel) - elif full_cmd.startswith('!ignore ') and self.is_admin(user): # Admin only - target_nick = full_cmd[8:].strip().lower() - await self.handle_ignore(nick, channel, target_nick) - elif full_cmd.startswith('!delignore ') and self.is_admin(user): # Admin only - target_nick = full_cmd[11:].strip().lower() - await self.handle_delignore(nick, channel, target_nick) - elif full_cmd.startswith('!giveitem ') and self.is_admin(user): # Admin only - parts = full_cmd[10:].split() - if len(parts) >= 2: - target_nick, item = parts[0], parts[1] - await self.handle_admin_giveitem(nick, channel, target_nick, item) - else: - self.send_message(channel, f"{nick} > Usage: !giveitem ") - elif full_cmd.startswith('!givexp ') and self.is_admin(user): # Admin only - parts = full_cmd[8:].split() - if len(parts) >= 2: - target_nick, amount = parts[0], parts[1] - await self.handle_admin_givexp(nick, channel, target_nick, amount) - else: - self.send_message(channel, f"{nick} > Usage: !givexp ") - - except Exception as e: - # Graceful degradation - log error but don't crash - self.logger.error(f"Command handling error for {user} in {channel}: {e}") - import traceback - self.logger.error(f"Full traceback: {traceback.format_exc()}") - - # Send a gentle error message to user - try: - nick = user.split('!')[0] if user and '!' in user else "Unknown" - error_msg = f"{nick} > Sorry, there was an error processing your command. Please try again." - if channel == self.config['nick']: # Private message - self.send_message(nick, error_msg) - else: # Channel message - self.send_message(channel, error_msg) - except Exception as send_error: - self.logger.error(f"Failed to send error message: {send_error}") - - async def handle_stats(self, nick, channel, user): - player = self.get_player(user) - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Get level and title - level, title = self.get_player_level(player['xp']) - xp_for_next = self.get_xp_for_next_level(player['xp']) - - # Calculate advanced stats - total_shots = player.get('caught', 0) + player.get('missed', 0) - effective_accuracy = (player.get('caught', 0) / total_shots * 100) if total_shots > 0 else 0 - average_time = (player.get('total_reflex_time', 0) / player.get('reflex_shots', 1)) if player.get('reflex_shots', 0) > 0 else 0 - - # Gun status - gun_status = "" - if player.get('gun_confiscated', False): - gun_status += f" {self.colors['red']}[CONFISCATED]{self.colors['reset']}" - if player.get('jammed', False): - gun_status += f" {self.colors['yellow']}[JAMMED]{self.colors['reset']}" - if player.get('explosive_ammo', False): - gun_status += f" {self.colors['orange']}[EXPLOSIVE]{self.colors['reset']}" - - # Duck stats with colors - duck_stats = [] - if player.get('caught', 0) > 0: - duck_stats.append(f"Shot:{player['caught']}") - if player.get('befriended', 0) > 0: - duck_stats.append(f"Befriended:{player['befriended']}") - if player.get('golden_ducks', 0) > 0: - duck_stats.append(f"{self.colors['yellow']}Golden:{player['golden_ducks']}{self.colors['reset']}") - - duck_display = f"Ducks:({', '.join(duck_stats)})" if duck_stats else "Ducks:0" - - # Main stats line - stats_line1 = f"{nick} > {duck_display} | Level {level}: {self.colors['cyan']}{title}{self.colors['reset']} | XP: {player['xp']}" - if xp_for_next > 0: - stats_line1 += f" (next: {xp_for_next})" - - # Combat stats line - karma_color = self.colors['green'] if player.get('karma', 0) >= 0 else self.colors['red'] - karma_display = f"{karma_color}Karma:{player.get('karma', 0)}{self.colors['reset']}" - - stats_line2 = f"{nick} > {karma_display} | Accuracy: {player['accuracy']}% (effective: {effective_accuracy:.1f}%) | Reliability: {self.calculate_gun_reliability(player)}%" - - # Equipment line - weapon_name = player.get('weapon', 'pistol').replace('_', ' ').title() - stats_line3 = f"{nick} > Weapon: {weapon_name}{gun_status} | Ammo: {player['ammo']}/{player['max_ammo']} | Chargers: {player['chargers']}/{player['max_chargers']}" - - # Advanced stats line - best_time = player.get('best_time', 999.9) - best_display = f"{best_time:.3f}s" if best_time < 999 else "none" - - stats_line4 = f"{nick} > Best time: {best_display} | Avg time: {average_time:.3f}s | Jams: {player.get('jammed_count', 0)} | Accidents: {player.get('accidents', 0)} | Lucky shots: {player.get('lucky_shots', 0)}" - - # Send all stats - await self.send_user_message(nick, channel, stats_line1) - await self.send_user_message(nick, channel, stats_line2) - await self.send_user_message(nick, channel, stats_line3) - await self.send_user_message(nick, channel, stats_line4) - - # Inventory display - if player.get('inventory'): - inventory_items = [] - shop_items = { - '1': 'Extra bullet', '2': 'Extra clip', '3': 'AP ammo', '4': 'Explosive ammo', - '5': 'Gun restore', '6': 'Grease', '7': 'Sight', '8': 'Detector', '9': 'Silencer', - '10': 'Clover', '11': 'Shotgun', '12': 'Rifle', '13': 'Sniper', '14': 'Auto shotgun', - '15': 'Sand', '16': 'Water', '17': 'Sabotage', '18': 'Life insurance', - '19': 'Liability insurance', '20': 'Decoy', '21': 'Bread', '22': 'Duck detector', '23': 'Mechanical duck' - } - - for item_id, count in player['inventory'].items(): - item_name = shop_items.get(item_id, f"Item #{item_id}") - inventory_items.append(f"{item_id}:{item_name}({count})") - - if inventory_items: - max_slots = self.get_config('economy.max_inventory_slots', 20) - total_items = sum(player['inventory'].values()) - inventory_display = f"{nick} > {self.colors['magenta']}Inventory ({total_items}/{max_slots}):{self.colors['reset']} {' | '.join(inventory_items[:10])}" - if len(inventory_items) > 10: - inventory_display += f" ... and {len(inventory_items) - 10} more" - await self.send_user_message(nick, channel, inventory_display) - - async def handle_rearm(self, nick, channel, user, target_nick): - """Rearm a player whose gun was confiscated""" - player = self.get_player(user) - target_nick_lower = target_nick.lower() - - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Check if target exists - if target_nick_lower not in self.players: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - return - - target_player = self.players[target_nick_lower] - - # Check if target's gun is confiscated - if not target_player.get('gun_confiscated', False): - self.send_message(channel, f"{nick} > {target_nick}'s gun is not confiscated!") - return - - # Admins can rearm anyone for free - is_admin = self.is_admin(user) - - if is_admin: - # Admin rearm - no cost, configurable restoration - target_player['gun_confiscated'] = False - - # Configure ammo restoration - if self.get_config('moderation.admin_rearm_gives_full_ammo', True): - target_player['ammo'] = target_player['max_ammo'] # Full ammo - ammo_text = "full ammo" - else: - target_player['ammo'] = min(target_player['ammo'] + 1, target_player['max_ammo']) # Just +1 ammo - ammo_text = "+1 ammo" - - # Configure charger restoration - if self.get_config('moderation.admin_rearm_gives_full_chargers', True): - target_player['chargers'] = target_player.get('max_chargers', 2) # Full chargers - charger_text = ", full chargers" - else: - charger_text = "" - - if target_nick_lower == nick.lower(): - self.send_message(channel, f"{nick} > {self.colors['green']}Admin command: Gun restored with {ammo_text}{charger_text}.{self.colors['reset']}") - else: - self.send_message(channel, f"{nick} > {self.colors['green']}Admin command: {target_nick}'s gun restored with {ammo_text}{charger_text}.{self.colors['reset']}") - self.save_database() - elif target_nick_lower == nick.lower(): - # Regular player rearming self - costs XP - rearm_cost = 40 - if player['xp'] < rearm_cost: - self.send_message(channel, f"{nick} > You need {rearm_cost} XP to rearm yourself (you have {player['xp']} XP)") - return - - player['xp'] -= rearm_cost - player['gun_confiscated'] = False - player['ammo'] = player['max_ammo'] # Full ammo when rearmed - self.send_message(channel, f"{nick} > {self.colors['green']}You rearmed yourself! [-{rearm_cost} XP] Gun restored with full ammo.{self.colors['reset']}") - self.save_player(user) - else: - # Regular player rearming someone else - costs XP (friendly gesture) - rearm_cost_xp = 5 - if player['xp'] < rearm_cost_xp: - self.send_message(channel, f"{nick} > You need {rearm_cost_xp} XP to rearm {target_nick} (you have {player['xp']} XP)") - return - - player['xp'] -= rearm_cost_xp - target_player['gun_confiscated'] = False - target_player['ammo'] = target_player['max_ammo'] # Full ammo when rearmed - self.send_message(channel, f"{nick} > {self.colors['green']}You rearmed {target_nick}! [-{rearm_cost_xp} XP] {target_nick}'s gun restored with full ammo.{self.colors['reset']}") - self.save_player(user) - self.save_database() - - async def handle_help(self, nick, channel): - help_lines = [ - f"{nick} > {self.colors['cyan']}🦆 DUCKHUNT 🦆{self.colors['reset']} !bang !bef !reload !stats !top !shop !buy ", - f"{nick} > {self.colors['yellow']}Golden ducks: 50 XP{self.colors['reset']} | {self.colors['red']}Gun jamming & ricochets ON{self.colors['reset']} | Timeout: {self.duck_timeout_min}-{self.duck_timeout_max}s" - ] - if self.is_admin(f"{nick}!*@*"): # Check if admin - help_lines.append(f"{nick} > {self.colors['red']}Admin:{self.colors['reset']} !duck !golden !ban !reset !resetdb !rearm !giveitem !givexp | /msg {self.config['nick']} restart|quit") - for line in help_lines: - self.send_message(channel, line) - - async def handle_output(self, nick, channel, user, output_type): - """Handle output mode setting (PUBLIC, NOTICE, or PRIVMSG)""" - player = self.get_player(user) - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Ensure player has settings (for existing players) - if 'settings' not in player: - default_mode = self.get_config('message_output.default_user_mode', 'PUBLIC') - player['settings'] = { - 'output_mode': default_mode - } - - output_type = output_type.upper() - - if output_type == 'PUBLIC': - player['settings']['output_mode'] = 'PUBLIC' - self.save_database() - self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}PUBLIC{self.colors['reset']} (channel messages)") - - elif output_type == 'NOTICE': - player['settings']['output_mode'] = 'NOTICE' - self.save_database() - self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}NOTICE{self.colors['reset']} (channel notices)") - - elif output_type == 'PRIVMSG': - player['settings']['output_mode'] = 'PRIVMSG' - self.save_database() - self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}PRIVMSG{self.colors['reset']} (private messages)") - - else: - current_mode = player['settings'].get('output_mode', 'NOTICE') - self.send_message(channel, f"{nick} > Current output mode: {self.colors['cyan']}{current_mode}{self.colors['reset']} | Usage: !output PUBLIC, !output NOTICE, or !output PRIVMSG") - - async def handle_ducktime(self, nick, channel): - """Show time until next duck spawn""" - current_time = time.time() - - # Check if there are active ducks - channel_ducks = self.ducks.get(channel, []) - alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] - - if alive_ducks: - self.send_message(channel, f"{nick} > {len(alive_ducks)} duck(s) currently active! Go hunt them!") - return - - # Show next spawn time if we have it - if channel in self.next_duck_spawn: - next_spawn = self.next_duck_spawn[channel] - time_until = max(0, next_spawn - current_time) - - if time_until > 0: - minutes = int(time_until // 60) - seconds = int(time_until % 60) - difficulty = self.duck_difficulty.get(channel, 1.0) - difficulty_text = f" (Difficulty: {difficulty:.1f}x)" if difficulty > 1.0 else "" - self.send_message(channel, f"{nick} > Next duck spawn in {self.colors['cyan']}{minutes}m {seconds}s{self.colors['reset']}{difficulty_text}") - else: - self.send_message(channel, f"{nick} > Duck should spawn any moment now...") - else: - # Estimate based on spawn range - min_min = self.duck_spawn_min // 60 - max_min = self.duck_spawn_max // 60 - self.send_message(channel, f"{nick} > Ducks spawn every {min_min}-{max_min} minutes (spawn time varies)") - - async def handle_lastduck(self, nick, channel): - """Show information about the last duck shot in this channel""" - if channel not in self.channel_records: - self.send_message(channel, f"{nick} > No duck records found for {channel}") - return - - last_duck = self.channel_records[channel].get('last_duck') - if not last_duck: - self.send_message(channel, f"{nick} > No ducks have been shot in {channel} yet") - return - - # Format the last duck info - hunter = last_duck['hunter'] - duck_type = last_duck['type'] - shot_time = last_duck['shot_time'] - time_ago = time.time() - last_duck['timestamp'] - - # Format time ago - if time_ago < 60: - time_ago_str = f"{int(time_ago)}s ago" - elif time_ago < 3600: - time_ago_str = f"{int(time_ago // 60)}m ago" - else: - time_ago_str = f"{int(time_ago // 3600)}h ago" - - duck_emoji = "🥇" if duck_type == "golden" else "🦆" - self.send_message(channel, f"{nick} > Last duck: {duck_emoji} {duck_type} duck shot by {self.colors['cyan']}{hunter}{self.colors['reset']} in {shot_time:.3f}s ({time_ago_str})") - - async def handle_records(self, nick, channel): - """Show channel records and statistics""" - if channel not in self.channel_records: - self.send_message(channel, f"{nick} > No records found for {channel}") - return - - records = self.channel_records[channel] - - # Header - self.send_message(channel, f"{nick} > {self.colors['yellow']}📊 {channel.upper()} RECORDS 📊{self.colors['reset']}") - - # Fastest shot - fastest = records.get('fastest_shot') - if fastest: - self.send_message(channel, f"🏆 Fastest shot: {self.colors['green']}{fastest['time']:.3f}s{self.colors['reset']} by {self.colors['cyan']}{fastest['hunter']}{self.colors['reset']} ({fastest['duck_type']} duck)") - - # Total stats - total_ducks = records.get('total_ducks', 0) - total_shots = records.get('total_shots', 0) - accuracy = (total_ducks / total_shots * 100) if total_shots > 0 else 0 - - self.send_message(channel, f"📈 Total: {total_ducks} ducks shot, {total_shots} shots fired ({accuracy:.1f}% accuracy)") - - # Current difficulty - difficulty = self.duck_difficulty.get(channel, 1.0) - if difficulty > 1.0: - self.send_message(channel, f"🧠 Duck intelligence: {self.colors['red']}{difficulty:.2f}x harder{self.colors['reset']} (they're learning!)") - else: - self.send_message(channel, f"🧠 Duck intelligence: Normal (fresh ducks)") - - async def handle_shop(self, nick, channel, user): - player = self.get_player(user) - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Create organized shop display - shop_items = { - 'ammo': [ - {'id': '1', 'name': 'Extra bullet', 'cost': 7}, - {'id': '2', 'name': 'Extra clip', 'cost': 20}, - {'id': '3', 'name': 'AP ammo', 'cost': 15}, - {'id': '4', 'name': 'Explosive ammo', 'cost': 25} - ], - 'weapons': [ - {'id': '11', 'name': 'Shotgun', 'cost': 100}, - {'id': '12', 'name': 'Assault rifle', 'cost': 200}, - {'id': '13', 'name': 'Sniper rifle', 'cost': 350}, - {'id': '14', 'name': 'Auto shotgun', 'cost': 500} - ], - 'upgrades': [ - {'id': '6', 'name': 'Grease', 'cost': 8}, - {'id': '7', 'name': 'Sight', 'cost': 6}, - {'id': '8', 'name': 'Infrared detector', 'cost': 15}, - {'id': '9', 'name': 'Silencer', 'cost': 5}, - {'id': '10', 'name': 'Four-leaf clover', 'cost': 13} - ], - 'special': [ - {'id': '5', 'name': 'Repurchase gun', 'cost': 40}, - {'id': '15', 'name': 'Sand', 'cost': 7}, - {'id': '16', 'name': 'Water bucket', 'cost': 10}, - {'id': '17', 'name': 'Sabotage', 'cost': 14}, - {'id': '20', 'name': 'Decoy', 'cost': 80}, - {'id': '21', 'name': 'Bread', 'cost': 50}, - {'id': '22', 'name': 'Duck detector', 'cost': 50}, - {'id': '23', 'name': 'Mechanical duck', 'cost': 50} - ], - 'insurance': [ - {'id': '18', 'name': 'Life insurance', 'cost': 10}, - {'id': '19', 'name': 'Liability insurance', 'cost': 5} - ] - } - - # Format each category - def format_items(items, color): - formatted = [] - for item in items: - formatted.append(f"{color}{item['id']}{self.colors['reset']}.{item['name']}({color}{item['cost']}xp{self.colors['reset']})") - return ' '.join(formatted) - - # Send shop header - self.send_message(channel, f"{nick} > {self.colors['yellow']}═══ DUCK HUNT SHOP ═══{self.colors['reset']} Your XP: {self.colors['green']}{player['xp']}{self.colors['reset']}") - - # Send categorized items - self.send_message(channel, f"{self.colors['cyan']}Ammo:{self.colors['reset']} {format_items(shop_items['ammo'], self.colors['cyan'])}") - self.send_message(channel, f"{self.colors['red']}Weapons:{self.colors['reset']} {format_items(shop_items['weapons'], self.colors['red'])}") - self.send_message(channel, f"{self.colors['green']}Upgrades:{self.colors['reset']} {format_items(shop_items['upgrades'], self.colors['green'])}") - self.send_message(channel, f"{self.colors['yellow']}Special:{self.colors['reset']} {format_items(shop_items['special'], self.colors['yellow'])}") - self.send_message(channel, f"{self.colors['magenta']}Insurance:{self.colors['reset']} {format_items(shop_items['insurance'], self.colors['magenta'])}") - - # Footer - self.send_message(channel, f"{self.colors['white']}Use {self.colors['cyan']}!shop {self.colors['white']} to purchase {self.colors['reset']}") - - async def handle_buy(self, nick, channel, item, user): - """Buy items and add to inventory""" - player = self.get_player(user) - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Check if inventory system is enabled - if not self.get_config('economy.inventory_system_enabled', True): - self.send_message(channel, f"{nick} > Inventory system is disabled!") - return - - # Initialize inventory if not exists - if 'inventory' not in player: - player['inventory'] = {} - - # Eggdrop-style shop items with XP costs - shop_items = { - '1': {'name': 'Extra bullet', 'cost': 7}, - '2': {'name': 'Extra clip', 'cost': 20}, - '3': {'name': 'AP ammo', 'cost': 15}, - '4': {'name': 'Explosive ammo', 'cost': 25}, - '5': {'name': 'Repurchase confiscated gun', 'cost': 40}, - '6': {'name': 'Grease', 'cost': 8}, - '7': {'name': 'Sight', 'cost': 6}, - '8': {'name': 'Infrared detector', 'cost': 15}, - '9': {'name': 'Silencer', 'cost': 5}, - '10': {'name': 'Four-leaf clover', 'cost': 13}, - '11': {'name': 'Shotgun', 'cost': 100}, - '12': {'name': 'Assault rifle', 'cost': 200}, - '13': {'name': 'Sniper rifle', 'cost': 350}, - '14': {'name': 'Automatic shotgun', 'cost': 500}, - '15': {'name': 'Handful of sand', 'cost': 7}, - '16': {'name': 'Water bucket', 'cost': 10}, - '17': {'name': 'Sabotage', 'cost': 14}, - '18': {'name': 'Life insurance', 'cost': 10}, - '19': {'name': 'Liability insurance', 'cost': 5}, - '20': {'name': 'Decoy', 'cost': 80}, - '21': {'name': 'Piece of bread', 'cost': 50}, - '22': {'name': 'Ducks detector', 'cost': 50}, - '23': {'name': 'Mechanical duck', 'cost': 50} - } - - if item not in shop_items: - self.send_message(channel, f"{nick} > Invalid item ID. Use !shop to see available items.") - return - - shop_item = shop_items[item] - cost = shop_item['cost'] - - if player['xp'] < cost: - self.send_message(channel, f"{nick} > Not enough XP! You need {cost} XP but only have {player['xp']}.") - return - - # Check inventory space - max_slots = self.get_config('economy.max_inventory_slots', 20) - if max_slots is None: - max_slots = 20 - total_items = sum(player['inventory'].values()) - if total_items >= max_slots: - self.send_message(channel, f"{nick} > Inventory full! ({total_items}/{max_slots}) Use items or increase capacity.") - return - - # Purchase the item and add to inventory - player['xp'] -= cost - if item in player['inventory']: - player['inventory'][item] += 1 - else: - player['inventory'][item] = 1 - - self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Added to inventory ({total_items + 1}/{max_slots})") - - # Save to database after purchase - self.save_player(user) - - async def handle_sell(self, nick, channel, item_id, user): - """Sell items from inventory for 70% of original cost""" - player = self.get_player(user) - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Check if inventory system is enabled - if not self.get_config('economy.inventory_system_enabled', True): - self.send_message(channel, f"{nick} > Inventory system is disabled!") - return - - # Initialize inventory if not exists - if 'inventory' not in player: - player['inventory'] = {} - - # Check if item is in inventory - if item_id not in player['inventory'] or player['inventory'][item_id] <= 0: - self.send_message(channel, f"{nick} > You don't have that item! Check !stats to see your inventory.") - return - - # Get shop item data for pricing - shop_items = { - '1': {'name': 'Extra bullet', 'cost': 7}, - '2': {'name': 'Extra clip', 'cost': 20}, - '3': {'name': 'AP ammo', 'cost': 15}, - '4': {'name': 'Explosive ammo', 'cost': 25}, - '5': {'name': 'Repurchase confiscated gun', 'cost': 40}, - '6': {'name': 'Grease', 'cost': 8}, - '7': {'name': 'Sight', 'cost': 6}, - '8': {'name': 'Infrared detector', 'cost': 15}, - '9': {'name': 'Silencer', 'cost': 5}, - '10': {'name': 'Four-leaf clover', 'cost': 13}, - '11': {'name': 'Shotgun', 'cost': 100}, - '12': {'name': 'Assault rifle', 'cost': 200}, - '13': {'name': 'Sniper rifle', 'cost': 350}, - '14': {'name': 'Automatic shotgun', 'cost': 500}, - '15': {'name': 'Handful of sand', 'cost': 7}, - '16': {'name': 'Water bucket', 'cost': 10}, - '17': {'name': 'Sabotage', 'cost': 14}, - '18': {'name': 'Life insurance', 'cost': 10}, - '19': {'name': 'Liability insurance', 'cost': 5}, - '20': {'name': 'Decoy', 'cost': 80}, - '21': {'name': 'Piece of bread', 'cost': 50}, - '22': {'name': 'Ducks detector', 'cost': 50}, - '23': {'name': 'Mechanical duck', 'cost': 50} - } - - if item_id not in shop_items: - self.send_message(channel, f"{nick} > Invalid item ID!") - return - - shop_item = shop_items[item_id] - original_cost = shop_item['cost'] - sell_price = int(original_cost * 0.7) # 70% of original cost - - # Remove item from inventory - player['inventory'][item_id] -= 1 - if player['inventory'][item_id] <= 0: - del player['inventory'][item_id] - - # Give XP back - player['xp'] += sell_price - - total_items = sum(player['inventory'].values()) - max_slots = self.get_config('economy.max_inventory_slots', 20) - - self.send_message(channel, f"{nick} > Sold {shop_item['name']} for {sell_price}xp! Inventory: ({total_items}/{max_slots})") - - # Save to database after sale - self.save_player(user) - - async def handle_use(self, nick, channel, item_id, user, target_nick=None): - """Use an item from inventory""" - player = self.get_player(user) - if not player: - self.send_message(channel, f"{nick} > Player data not found!") - return - - # Check if item is in inventory - if item_id not in player['inventory'] or player['inventory'][item_id] <= 0: - self.send_message(channel, f"{nick} > You don't have that item! Check !stats to see your inventory.") - return - - # Get shop item data for reference - shop_items = { - '1': {'name': 'Extra bullet', 'effect': 'ammo'}, - '2': {'name': 'Extra clip', 'effect': 'max_ammo'}, - '3': {'name': 'AP ammo', 'effect': 'accuracy'}, - '4': {'name': 'Explosive ammo', 'effect': 'explosive'}, - '5': {'name': 'Repurchase confiscated gun', 'effect': 'gun'}, - '6': {'name': 'Grease', 'effect': 'reliability'}, - '7': {'name': 'Sight', 'effect': 'accuracy'}, - '8': {'name': 'Infrared detector', 'effect': 'detector'}, - '9': {'name': 'Silencer', 'effect': 'silencer'}, - '10': {'name': 'Four-leaf clover', 'effect': 'luck'}, - '11': {'name': 'Shotgun', 'effect': 'shotgun'}, - '12': {'name': 'Assault rifle', 'effect': 'rifle'}, - '13': {'name': 'Sniper rifle', 'effect': 'sniper'}, - '14': {'name': 'Automatic shotgun', 'effect': 'auto_shotgun'}, - '15': {'name': 'Handful of sand', 'effect': 'sand'}, - '16': {'name': 'Water bucket', 'effect': 'water'}, - '17': {'name': 'Sabotage', 'effect': 'sabotage'}, - '18': {'name': 'Life insurance', 'effect': 'life_insurance'}, - '19': {'name': 'Liability insurance', 'effect': 'liability'}, - '20': {'name': 'Decoy', 'effect': 'decoy'}, - '21': {'name': 'Piece of bread', 'effect': 'bread'}, - '22': {'name': 'Ducks detector', 'effect': 'duck_detector'}, - '23': {'name': 'Mechanical duck', 'effect': 'mechanical'} - } - - if item_id not in shop_items: - self.send_message(channel, f"{nick} > Invalid item ID!") - return - - shop_item = shop_items[item_id] - effect = shop_item['effect'] - - # Determine target player - if target_nick and target_nick.lower() != nick.lower(): - # Using on someone else - target_nick_lower = target_nick.lower() - if target_nick_lower not in self.players: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - return - target_player = self.players[target_nick_lower] - using_on_other = True - else: - # Using on self - target_player = player - target_nick = nick - using_on_other = False - - # Remove item from inventory - player['inventory'][item_id] -= 1 - if player['inventory'][item_id] <= 0: - del player['inventory'][item_id] - - # Apply item effects - if effect == 'ammo': - target_player['ammo'] = min(target_player['max_ammo'], target_player['ammo'] + 1) - if using_on_other: - self.send_message(channel, f"{nick} > Used {shop_item['name']} on {target_nick}! +1 ammo") - else: - self.send_message(channel, f"{nick} > Used {shop_item['name']}! +1 ammo") - elif effect == 'water': - # Water bucket - splash attack on target player - if using_on_other: - # Reduce target's accuracy temporarily - target_player['accuracy'] = max(10, target_player['accuracy'] - 15) - self.send_message(channel, f"{nick} > *SPLASH!* You soaked {target_nick} with water! Their accuracy reduced by 15%!") - else: - self.send_message(channel, f"{nick} > You splashed yourself with water... why?") - elif effect == 'sand': - # Handful of sand - blind target temporarily - if using_on_other: - target_player['accuracy'] = max(5, target_player['accuracy'] - 20) - self.send_message(channel, f"{nick} > *POCKET SAND!* You threw sand in {target_nick}'s eyes! Their accuracy reduced by 20%!") - else: - self.send_message(channel, f"{nick} > You threw sand in your own eyes... brilliant strategy!") - # Add more effects as needed... - else: - # Default effects for other items - self.send_message(channel, f"{nick} > Used {shop_item['name']}! (Effect: {effect})") - - # Save changes - self.save_player(user) - if using_on_other: - # Save target player too if different - target_user = f"{target_nick.lower()}!user@host" # Simplified - would need real user data - self.save_database() - - async def handle_trade(self, nick, channel, user, target_nick, item, amount): - """Trade items with other players""" - player = self.get_player(user) - if not player: - return - - try: - amount = int(amount) - except ValueError: - self.send_message(channel, f"{nick} > Amount must be a number!") - return - - if amount <= 0: - self.send_message(channel, f"{nick} > Amount must be positive!") - return - - if amount > 10000: # Prevent excessive amounts - self.send_message(channel, f"{nick} > Amount too large! Maximum: 10,000") - return - - # Find target player (simplified - would need to track online users in real implementation) - if item == 'coins': - if player['coins'] < amount: - self.send_message(channel, f"{nick} > You don't have {amount} coins!") - return - player['coins'] -= amount - self.send_message(channel, f"{nick} > Offering {amount} coins to {target_nick}. They can !accept or !decline.") - # In real implementation, store pending trade - - elif item == 'ammo': - if player['ammo'] < amount: - self.send_message(channel, f"{nick} > You don't have {amount} ammo!") - return - self.send_message(channel, f"{nick} > Offering {amount} ammo to {target_nick}.") - - elif item == 'chargers': - if player['chargers'] < amount: - self.send_message(channel, f"{nick} > You don't have {amount} chargers!") - return - self.send_message(channel, f"{nick} > Offering {amount} chargers to {target_nick}.") - - else: - self.send_message(channel, f"{nick} > Can't trade '{item}'. Use: coins, ammo, or chargers") - - self.save_player(user) - - async def handle_listplayers(self, nick, channel): - """Admin command to list all players""" - if not self.players: - self.send_message(channel, f"{nick} > No players in database.") - return - - player_list = [] - for nick_key, data in self.players.items(): - shot_count = data['caught'] - befriended_count = data.get('befriended', 0) - total_ducks = shot_count + befriended_count - player_list.append(f"{nick_key}(Ducks:{total_ducks},Shot:{shot_count},Befriended:{befriended_count})") - - players_str = " | ".join(player_list[:10]) # Limit to first 10 - if len(self.players) > 10: - players_str += f" ... and {len(self.players) - 10} more" - - self.send_message(channel, f"{nick} > Players: {players_str}") - - async def handle_ban(self, nick, channel, target_nick): - """Admin command to ban a player""" - target_nick_lower = target_nick.lower() - if target_nick_lower in self.players: - del self.players[target_nick_lower] - self.send_message(channel, f"{nick} > Banned and reset {target_nick}") - self.save_database() - else: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - - async def handle_reset(self, nick, channel, target_nick): - """Admin command to reset a player's stats""" - target_nick_lower = target_nick.lower() - if target_nick_lower in self.players: - # Reset to defaults - self.players[target_nick_lower] = { - 'caught': 0, 'ammo': 10, 'max_ammo': 10, - 'chargers': 2, 'max_chargers': 2, 'xp': 0, - 'accuracy': 85, 'reliability': 90, 'gun_level': 1, - 'luck': 0, 'gun_type': 'pistol' - } - self.send_message(channel, f"{nick} > Reset {target_nick}'s stats to defaults") - self.save_database() - else: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - - async def handle_reset_database(self, nick, channel, user): - """Admin command to reset entire database - requires confirmation""" - self.send_message(channel, f"{nick} > {self.colors['red']}⚠️ DATABASE RESET WARNING ⚠️{self.colors['reset']}") - self.send_message(channel, f"{nick} > This will DELETE ALL player data, statistics, and progress!") - self.send_message(channel, f"{nick} > {self.colors['yellow']}Players affected: {len(self.players)}{self.colors['reset']}") - self.send_message(channel, f"{nick} > To confirm, type: {self.colors['cyan']}!resetdb confirm DESTROY_ALL_DATA{self.colors['reset']}") - self.send_message(channel, f"{nick} > {self.colors['red']}This action CANNOT be undone!{self.colors['reset']}") - - async def handle_reset_database_confirm(self, nick, channel, user, confirmation): - """Confirm and execute database reset""" - if confirmation != "DESTROY_ALL_DATA": - self.send_message(channel, f"{nick} > {self.colors['red']}Incorrect confirmation code. Database reset cancelled.{self.colors['reset']}") - return - - # Log the reset action - self.logger.warning(f"DATABASE RESET initiated by admin {nick} - All player data will be destroyed") - - # Backup current database - import shutil - backup_name = f"duckhunt_backup_{int(time.time())}.json" - try: - shutil.copy2(self.db_file, backup_name) - self.send_message(channel, f"{nick} > {self.colors['cyan']}Database backed up to: {backup_name}{self.colors['reset']}") - except Exception as e: - self.logger.error(f"Failed to create backup: {e}") - self.send_message(channel, f"{nick} > {self.colors['red']}Warning: Could not create backup!{self.colors['reset']}") - - # Clear all data - player_count = len(self.players) - self.players.clear() - self.ducks.clear() - self.ignored_nicks.clear() - - # Save empty database - self.save_database() - - # Confirmation messages - self.send_message(channel, f"{nick} > {self.colors['green']}✅ DATABASE RESET COMPLETE{self.colors['reset']}") - self.send_message(channel, f"{nick} > {self.colors['yellow']}{player_count} player records deleted{self.colors['reset']}") - self.send_message(channel, f"{nick} > All ducks cleared, fresh start initiated") - self.logger.warning(f"Database reset completed by {nick} - {player_count} players deleted") - - async def handle_restart(self, nick, channel): - """Admin command to restart the bot""" - self.send_message(channel, f"{nick} > Restarting bot...") - self.logger.info(f"Bot restart requested by {nick}") - - # Close connections gracefully - if self.writer: - self.writer.close() - await self.writer.wait_closed() - - # Save any pending data - self.save_database() - - # Restart the Python process - self.logger.info("Restarting Python process...") - python = sys.executable - script = sys.argv[0] - args = sys.argv[1:] - - # Use subprocess to restart - subprocess.Popen([python, script] + args) - - # Exit current process - sys.exit(0) - - async def handle_quit(self, nick, channel): - """Admin command to quit the bot""" - self.send_message(channel, f"{nick} > Shutting down bot...") - self.logger.info(f"Bot shutdown requested by {nick}") - # Close connections gracefully - if self.writer: - self.writer.close() - await self.writer.wait_closed() - # Exit with code 0 for normal shutdown - import sys - sys.exit(0) - - async def handle_ignore(self, nick, channel, target_nick): - """Admin command to ignore a user""" - if target_nick in self.ignored_nicks: - self.send_message(channel, f"{nick} > {target_nick} is already ignored!") - return - - self.ignored_nicks.add(target_nick) - self.send_message(channel, f"{nick} > Now ignoring {target_nick}. Total ignored: {len(self.ignored_nicks)}") - self.logger.info(f"{nick} added {target_nick} to ignore list") - - async def handle_delignore(self, nick, channel, target_nick): - """Admin command to stop ignoring a user""" - if target_nick not in self.ignored_nicks: - self.send_message(channel, f"{nick} > {target_nick} is not ignored!") - return - - self.ignored_nicks.remove(target_nick) - self.send_message(channel, f"{nick} > No longer ignoring {target_nick}. Total ignored: {len(self.ignored_nicks)}") - self.logger.info(f"{nick} removed {target_nick} from ignore list") - - async def handle_admin_giveitem(self, nick, channel, target_nick, item): - """Admin command to give an item to a player""" - target_nick_lower = target_nick.lower() - - # Check if target exists - if target_nick_lower not in self.players: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - return - - # Shop items reference for item names - shop_items = { - '1': {'name': 'Extra bullet', 'effect': 'ammo'}, - '2': {'name': 'Extra clip', 'effect': 'max_ammo'}, - '3': {'name': 'AP ammo', 'effect': 'accuracy'}, - '4': {'name': 'Explosive ammo', 'effect': 'explosive'}, - '5': {'name': 'Repurchase confiscated gun', 'effect': 'gun'}, - '6': {'name': 'Grease', 'effect': 'reliability'}, - '7': {'name': 'Sight', 'effect': 'accuracy'}, - '8': {'name': 'Infrared detector', 'effect': 'detector'}, - '9': {'name': 'Silencer', 'effect': 'silencer'}, - '10': {'name': 'Four-leaf clover', 'effect': 'luck'}, - '11': {'name': 'Sunglasses', 'effect': 'sunglasses'}, - '12': {'name': 'Spare clothes', 'effect': 'clothes'}, - '13': {'name': 'Brush for gun', 'effect': 'brush'}, - '14': {'name': 'Mirror', 'effect': 'mirror'}, - '15': {'name': 'Handful of sand', 'effect': 'sand'}, - '16': {'name': 'Water bucket', 'effect': 'water'}, - '17': {'name': 'Sabotage', 'effect': 'sabotage'}, - '18': {'name': 'Life insurance', 'effect': 'life_insurance'}, - '19': {'name': 'Liability insurance', 'effect': 'liability'}, - '20': {'name': 'Decoy', 'effect': 'decoy'}, - '21': {'name': 'Piece of bread', 'effect': 'bread'}, - '22': {'name': 'Ducks detector', 'effect': 'duck_detector'}, - '23': {'name': 'Mechanical duck', 'effect': 'mechanical'} - } - - if item not in shop_items: - self.send_message(channel, f"{nick} > Invalid item ID '{item}'. Use item IDs 1-23.") - return - - target_player = self.players[target_nick_lower] - shop_item = shop_items[item] - effect = shop_item['effect'] - - # Apply the item effect - if effect == 'ammo': - target_player['ammo'] = min(target_player['ammo'] + 1, target_player['max_ammo']) - elif effect == 'max_ammo': - target_player['max_ammo'] += 1 - target_player['ammo'] = target_player['max_ammo'] # Fill ammo - elif effect == 'accuracy': - target_player['accuracy'] = min(target_player['accuracy'] + 5, 100) - elif effect == 'explosive': - target_player['explosive_ammo'] = True - elif effect == 'gun': - target_player['gun_confiscated'] = False - target_player['ammo'] = target_player['max_ammo'] - elif effect == 'reliability': - target_player['reliability'] = min(target_player['reliability'] + 5, 100) - elif effect == 'luck': - target_player['luck'] = target_player.get('luck', 0) + 1 - # Add other effects as needed - - self.send_message(channel, f"{nick} > {self.colors['green']}Gave {shop_item['name']} to {target_nick}!{self.colors['reset']}") - self.save_database() - - async def handle_admin_givexp(self, nick, channel, target_nick, amount): - """Admin command to give XP to a player""" - target_nick_lower = target_nick.lower() - - # Check if target exists - if target_nick_lower not in self.players: - self.send_message(channel, f"{nick} > Player {target_nick} not found!") - return - - try: - xp_amount = int(amount) - except ValueError: - self.send_message(channel, f"{nick} > Amount must be a number!") - return - - if abs(xp_amount) > 50000: # Prevent excessive XP changes - self.send_message(channel, f"{nick} > XP amount too large! Maximum: ±50,000") - return - - target_player = self.players[target_nick_lower] - old_xp = target_player['xp'] - target_player['xp'] = max(0, target_player['xp'] + xp_amount) # Prevent negative XP - - color = self.colors['green'] if xp_amount >= 0 else self.colors['red'] - sign = '+' if xp_amount >= 0 else '' - self.send_message(channel, f"{nick} > {color}Gave {sign}{xp_amount} XP to {target_nick}! (Total: {target_player['xp']} XP){self.colors['reset']}") - self.save_database() - - def get_duck_spawn_message(self): - """Get a random duck spawn message with different types""" - duck_types = [ - {"msg": "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O< QUACK", "type": "normal"}, # Normal duck - {"msg": "-._..-'`'°-,_,.-'`'°-,_,.-'`'°-,_,.-° \\_o< A duck waddles by! QUACK QUACK", "type": "normal"}, # Waddling duck - {"msg": "~~~°*°~~~°*°~~~°*°~~~ \\_O< SPLASH! A duck lands in the water! QUACK!", "type": "normal"}, # Water duck - {"msg": "***GOLDEN*** \\_O< *** A golden duck appears! *** QUACK QUACK! ***GOLDEN***", "type": "golden"}, # Golden duck (rare) - {"msg": "°~°*°~°*°~° \\_o< Brrr! A winter duck appears! QUACK!", "type": "normal"}, # Winter duck - {"msg": ".,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O< A spring duck blooms into view! QUACK!", "type": "normal"}, # Spring duck - {"msg": "***ZAP*** \\_O< BZZT! An electric duck sparks to life! QUACK! ***ZAP***", "type": "normal"}, # Electric duck - {"msg": "~*~*~*~ \\_o< A sleepy night duck appears... *yawn* quack...", "type": "normal"}, # Night duck - ] - - # Golden duck is rare (5% chance) - if random.random() < 0.05: - golden_duck = [d for d in duck_types if d["type"] == "golden"][0] - return golden_duck - else: - # Choose from normal duck types - normal_ducks = [d for d in duck_types if d["type"] == "normal"] - return random.choice(normal_ducks) - - async def spawn_duck_now(self, channel, force_golden=False): - """Admin command to spawn a duck immediately""" - # Create duck with unique ID and type - duck_id = str(uuid.uuid4())[:8] # Short ID for easier tracking - - if force_golden: - # Force spawn a golden duck - duck_info = { - "msg": f"{self.colors['yellow']}***GOLDEN***{self.colors['reset']} \\_$< {self.colors['yellow']}*** A golden duck appears! ***{self.colors['reset']} QUACK QUACK! {self.colors['yellow']}***GOLDEN***{self.colors['reset']}", - "type": "golden" - } - else: - duck_info = self.get_duck_spawn_message() - - duck_timeout = random.randint(self.duck_timeout_min, self.duck_timeout_max) - duck = { - 'alive': True, - 'spawn_time': time.time(), - 'id': duck_id, - 'type': duck_info['type'], - 'message': duck_info['msg'], - 'timeout': duck_timeout - } - - # Initialize channel duck list if needed - if channel not in self.ducks: - self.ducks[channel] = [] - - # Add duck to channel - self.ducks[channel].append(duck) - - # Send spawn message - self.send_message(channel, duck_info['msg']) - self.logger.info(f"Admin spawned {duck_info['type']} duck {duck_id} in {channel}") - return True - return True # Return True to indicate duck was spawned - - async def spawn_ducks(self): - # Spawn first duck immediately after joining - await asyncio.sleep(5) # Brief delay for players to see the bot joined - for channel in self.channels_joined: - await self.spawn_duck_now(channel) - - # Start duck timeout checker - asyncio.create_task(self.duck_timeout_checker()) - - while not self.shutdown_requested: - wait_time = random.randint(self.duck_spawn_min, self.duck_spawn_max) - self.logger.info(f"Waiting {wait_time//60}m {wait_time%60}s for next duck") - - # Set next spawn time for all channels - next_spawn_time = time.time() + wait_time - for channel in self.channels_joined: - self.next_duck_spawn[channel] = next_spawn_time - - # Sleep in chunks to check shutdown flag - for _ in range(wait_time): - if self.shutdown_requested: - self.logger.info("Duck spawning stopped due to shutdown request") - return - await asyncio.sleep(1) - - # Spawn only one duck per channel if no alive ducks exist - for channel in self.channels_joined: - if self.shutdown_requested: - return - - # Check if there are any alive ducks in this channel - channel_ducks = self.ducks.get(channel, []) - alive_ducks = [duck for duck in channel_ducks if duck.get('alive')] - - # Only spawn if no ducks are alive (one duck at a time naturally) - if not alive_ducks: - await self.spawn_duck_now(channel) - break # Only spawn in the first available channel - - async def duck_timeout_checker(self): - """Remove ducks that have been around too long""" - while not self.shutdown_requested: - await asyncio.sleep(10) # Check every 10 seconds - current_time = time.time() - - for channel in list(self.ducks.keys()): - if channel in self.ducks: - ducks_to_remove = [] - for i, duck in enumerate(self.ducks[channel]): - duck_timeout = duck.get('timeout', 60) # Use individual timeout or default to 60 - if duck['alive'] and (current_time - duck['spawn_time']) > duck_timeout: - # Duck wandered off - ducks_to_remove.append(i) - self.send_message(channel, f"A duck wandered off... *quack quack* (timeout after {duck_timeout}s)") - self.logger.info(f"Duck {duck['id']} timed out in {channel}") - - # Remove timed out ducks (in reverse order to maintain indices) - for i in reversed(ducks_to_remove): - del self.ducks[channel][i] - - async def listen(self): - """Listen for IRC messages with shutdown handling""" - while not self.shutdown_requested: - try: - if not self.reader: - self.logger.error("No reader available") - break - - # Use timeout to allow checking shutdown flag - try: - line = await asyncio.wait_for(self.reader.readline(), timeout=1.0) - except asyncio.TimeoutError: - continue # Check shutdown flag - - if not line: - self.logger.warning("Connection closed by server") - break - - line = line.decode(errors='ignore').strip() - if not line: - continue - - self.logger.debug(f"<- {line}") - - if line.startswith('PING'): - self.send_raw('PONG ' + line.split()[1]) - continue - - prefix, command, params, trailing = parse_message(line) - - except Exception as e: - self.logger.error(f"Error in listen loop: {e}") - await asyncio.sleep(1) # Brief pause before retry - continue - - # Handle SASL authentication responses - if command == 'CAP': - await self.sasl_handler.handle_cap_response(params, trailing) - - elif command == 'AUTHENTICATE': - await self.sasl_handler.handle_authenticate_response(params) - - elif command in ['903', '904', '905', '906', '907', '908']: # SASL responses - await self.sasl_handler.handle_sasl_result(command, params, trailing) - - elif command == '001': # Welcome - self.registered = True - auth_status = " (SASL authenticated)" if self.sasl_handler.is_authenticated() else "" - self.logger.info(f"Successfully registered!{auth_status}") - - # If SASL failed, try NickServ identification - if not self.sasl_handler.is_authenticated(): - await self.attempt_nickserv_auth() - - for chan in self.config['channels']: - self.logger.info(f"Joining {chan}") - self.send_raw(f'JOIN {chan}') - - elif command == 'JOIN' and prefix and prefix.startswith(self.config['nick']): - channel = trailing or (params[0] if params else '') - if channel: - self.channels_joined.add(channel) - self.logger.info(f"Successfully joined {channel}") - - elif command == 'PRIVMSG' and trailing: - target = params[0] if params else '' - sender = prefix.split('!')[0] if prefix else '' - - # Handle NickServ responses - if sender.lower() == 'nickserv': - await self.handle_nickserv_response(trailing) - elif trailing == 'VERSION': - self.send_raw(f'NOTICE {sender} :VERSION DuckHunt Bot v1.0') - else: - await self.handle_command(prefix, target, trailing) - - async def cleanup(self): - """Enhanced cleanup with graceful shutdown""" - self.logger.info("Starting cleanup process...") - - try: - # Cancel all running tasks - for task in self.running_tasks.copy(): - if not task.done(): - self.logger.debug(f"Cancelling task: {task.get_name()}") - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - except Exception as e: - self.logger.error(f"Error cancelling task: {e}") - - # Send goodbye message to all channels - if self.writer and not self.writer.is_closing(): - for channel in self.channels_joined: - self.send_message(channel, "🦆 DuckHunt Bot shutting down. Thanks for playing! 🦆") - await asyncio.sleep(0.1) # Brief delay between messages - - self.send_raw('QUIT :DuckHunt Bot shutting down gracefully') - await asyncio.sleep(1.0) # Give time for QUIT and messages to send - - self.writer.close() - await self.writer.wait_closed() - self.logger.info("IRC connection closed") - - # Final database save with verification - self.save_database() - self.logger.info(f"Final database save completed - {len(self.players)} players saved") - - # Clear in-memory data - self.players.clear() - self.ducks.clear() - self.command_cooldowns.clear() - - self.logger.info("Cleanup completed successfully") - - except Exception as e: - self.logger.error(f"Error during cleanup: {e}") - import traceback - traceback.print_exc() - - async def run(self): - """Main bot entry point with enhanced shutdown handling""" - try: - # Setup signal handlers - self.setup_signal_handlers() - - self.logger.info("Starting DuckHunt Bot...") - self.load_database() - await self.connect() - - # Create and track main tasks - listen_task = asyncio.create_task(self.listen(), name="listen") - duck_task = asyncio.create_task(self.wait_and_spawn_ducks(), name="duck_spawner") - - self.running_tasks.add(listen_task) - self.running_tasks.add(duck_task) - - # Main execution loop with shutdown monitoring - done, pending = await asyncio.wait( - [listen_task, duck_task], - return_when=asyncio.FIRST_COMPLETED - ) - - # If we get here, one task completed (likely due to error or shutdown) - if self.shutdown_requested: - self.logger.info("Shutdown requested, stopping all tasks...") - else: - self.logger.warning("A main task completed unexpectedly") - - # Cancel remaining tasks - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - except KeyboardInterrupt: - self.logger.info("Keyboard interrupt received") - self.shutdown_requested = True - except Exception as e: - self.logger.error(f"Fatal error in main loop: {e}") - import traceback - traceback.print_exc() - finally: - await self.cleanup() - - async def wait_and_spawn_ducks(self): - """Duck spawning with shutdown handling""" - # Wait for registration and channel joins - while not self.registered or not self.channels_joined and not self.shutdown_requested: - await asyncio.sleep(1) - - if self.shutdown_requested: - return - - self.logger.info("Starting duck spawning...") - await self.spawn_ducks() - -def main(): - """Enhanced main entry point with better shutdown handling""" - bot = None - try: - # Load configuration - with open('config.json') as f: - config = json.load(f) - - # Create bot instance - bot = SimpleIRCBot(config) - bot.logger.info("DuckHunt Bot initializing...") - - # Run bot with graceful shutdown - try: - asyncio.run(bot.run()) - except KeyboardInterrupt: - bot.logger.info("Keyboard interrupt received in main") - except Exception as e: - bot.logger.error(f"Runtime error: {e}") - import traceback - traceback.print_exc() - - bot.logger.info("DuckHunt Bot shutdown complete") - - except KeyboardInterrupt: - print("\n🦆 DuckHunt Bot stopped by user") - except FileNotFoundError: - print("❌ Error: config.json not found") - print("Please create a config.json file with your IRC server settings") - except json.JSONDecodeError as e: - print(f"❌ Error: Invalid config.json - {e}") - print("Please check your config.json file syntax") - except Exception as e: - print(f"💥 Unexpected error: {e}") - import traceback - traceback.print_exc() - finally: - # Ensure final message - print("🦆 Thanks for using DuckHunt Bot!") - -if __name__ == '__main__': - main() diff --git a/duckhunt/src/__pycache__/auth.cpython-312.pyc b/duckhunt/src/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index fc6416394d90b6814b930ca95ac728ed980a0251..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6452 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 Apa1{> diff --git a/duckhunt/src/__pycache__/db.cpython-312.pyc b/duckhunt/src/__pycache__/db.cpython-312.pyc deleted file mode 100644 index a8557e03aeac0cd34e13ac306600dd62dc1436c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6838 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

}2 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 diff --git a/duckhunt/src/__pycache__/items.cpython-312.pyc b/duckhunt/src/__pycache__/items.cpython-312.pyc deleted file mode 100644 index 8e4b18cf3f978626f91f52ad53d12c04438c3351..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2878 zcmbtWU2Gdw7QVJ;#&-NWaekV=#-&tU^V7iYKA@JZ>Ng=bU@a_5IE{bH9nj1_(T#R==$N3r0WUWd9L*gS}T^ut_MPK7*8rpUe^Jzd>l= zBfn=+_PH_vWMW_DcV!aDf_+)Qm4!eS?#qOhFEh|ZM}>;#&ck4DvPWUCNiQ1Z@b0`w0i-df?M5x@K0c(RJxh`)Hu`xO1n_qy+Oa&12y4w%sZZu|N;AuM{lMTtCmEv%9bIP48QzwLv*kFol_ zz9BXq>qE~mv5&EW*yNg9I)|5+NJc0y+-w)~mi5vEGNVq-8V};HUbne)X?baBxxj^D zwwUMA-11^BU*N%PacQ}j&t?2PP%$hnFw-zBq$i!ecWS0;*0h<5X{@zoD5PbzS+{2_ zR+-Ult!ABTw0L4&vlTCtf)?$uenOvnNc9JBYW*9aLw-sOzx~EP-*{KOmHIYu<}JaA zB;NVc=J}iF->q(^&fbZfby6cZy&au$V)8qs&GOCitI@D!@}Lt>I)ka*STHViF7FHxdD4-`A5ul7P7YNk9eDzb9XWMm z)wBcJ!>WYTncEp8lDz8=i_?37{gJmh+}EKlxSx7^InW&Gf?gjy*af{l2>ov>SQelM zVh2pR-4CtARw^^ZBQI-e!>oIKE&?3U z|NHmvcduY186gF*k_o!;*pn!E3gKyla|km4JYqGNrc%WMCtNJ*uNoSQgQB~E5Jfxe zeg)Y0X`DQca0Y;fXS1b+OUm3Tlv!zhc?AbzapCe}o}ERb2g#AaDZ-!OwN?NyIVPO( z31?)?8J}`SPdJlPkQ}nqS-=b!N8!i`XMECAIinN1iIB_m2$5ZRZ{e7?Febg3_X1*= zLxk`5xxMqCIYPLf3R?nv@pHSl!jq0wqlt$4`<;6=8+sL+0b`X zM$;&BnRVID2p+u>Rcq?H#^pZEP-*(By2iMGG`8c#=GvOZ(z8v&&}@r60h@Cflx9b?E;FQNB9c>4_XbaqN_$bncrv_rltQ?OBc;%om$;rfqWi;Z=$Mrv5#RaR9eXv za@k^zOU2dM*?f_Q^H;AdE|(TwI>H&A7DIRuUMmlPoK0i#O`UX-@@`m6N}c(gB#}?` zg-&X0HzK(uK1k%#u6!Ddom3j+vh?PHM-uuyL=x5qK6{uUM@WLZiA$0M_Yt_8Yz2Uz zFc5|}lqhICdLr3=Wx#D?QMKTBOZs}%W_Uqhcr$QMxW|!K67I1k9u%8fWNkUWVQX~? z*MTK19kKF+kNcRfY<3*g+8_*S95|a?;UB- zEzHfY@M!+(O1_ZG=ai-F92aL7t}N#sF09M=pAd9-EzA?I>q6Zm2c3yYASoqvUO30Htysqw6ubU2TxWl!5FZ8_k4Y|k)umZ0@ u*0TUR0iVzJ9f{o)W_;7T1mNzPZ^9?-5`eqsM{opi_nDMW`h@_viSZjL^R7Ao diff --git a/duckhunt/src/__pycache__/logging_utils.cpython-312.pyc b/duckhunt/src/__pycache__/logging_utils.cpython-312.pyc deleted file mode 100644 index 360b144a15da342f5ea0e296b7da723560073854..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1724 zcmZux-D@LN6u&cbC!ftnKSICUShs0uUN+Tr6{U!~*|wWf6G?R!Qe+r&Cu!s4!>`2)r=pB4xeNz3ukuA9BXEdE^N0wWh+qX3 zT%82xau0Gd%7{dSh#uSO<~g1?Ho1&*jf*<}VUA>!qt1$~8LP6I?T&P|jgx6m9zkIW zV#Q>5!V#Vb1QU@si1Vo1i@oe&LJj-_#jX`HEIC%=}TW%Mbh&A;x720j;UN^Y{+kJ@$x7W_U!C#$=@WZfY zLoghjt=poBzV1cH)j*8R~Pc5d>OH{}!26d~- z7m8YviwYI>YDLkgBWLvx`hs;u%~6M@WXl?%VwN?SVx>f~R4D3s>XxLeD(ku=>8zSD zMzxpG*h;ym#31_9wHT>pKU=AmjF_%vV`@2{UoGXOs5IT#)KDupuereD7$EO*h7-f4zPXytO5^0{!1S+i72pk$qR>1o9#HF_$E!&ENs4mX5V&;K>-RNw85)}?i!nu?1omPR{nb0#3XZh;ueCrK2)Ba6 z|N5MMM{VxEAQmT%5io6}vypo5LZj|MqwbWA3lL+_u^@)vz+GgtuNa|1HxMC+k%`VA zz;{Jn0km`YTshYrAPEM;j70#=&MG#Ag0HiFE=}Bt;E>4|0_UENW(pS~0JN9s9K;;F zLnbe|I}s*t!FYXYd16Y|v&L#siRzz>O-!8uII6!3W>L{~IS;d*~Rt{T=Q(jHwg&fXE~kqPOldzv`Z zz@eS|Ar6D}DD_opXZ8?}v<6?P%^l)_mbbU|fd#;*OM4AY)FQyBJ6A1bjdEGlHHJFs zK5K-Ha`sAFl|Dl@df5QHbj{d9^qzJTOa>F3z2e6L$8pb*|2gvfiLRYuA2+e{pc$HM bgeFfB7{_5W82S0_UuK@ooFW(3XJz~!YR!pg diff --git a/duckhunt/src/__pycache__/utils.cpython-312.pyc b/duckhunt/src/__pycache__/utils.cpython-312.pyc deleted file mode 100644 index 67198ac37ad2de6720dc660e6db7190a8eea9eab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 705 zcmZuv&ubGw6n?Wml9DvmD5WN)m>wf(L%j$^gi5WYNDqn!MUd^zHoNWaCeCcMkxiR| z;Km+YPmOvo$4LGG{{U}Z3iYrq-aPdt1U%&A+qD5H{ouXfz4v`@zJd9c$)o`BMgOW- z2*9tX7y;`TgNSkn6kfq&Xpo|yjwaDq!VobAe=s47Gzo^_!q)#RoJF8Q|Fp}lxIje; zdDx27XFw$?_Jx1VCwMkV6R7OMb6}IW`8s(iaQE( z_zT0)Lz&l1!wE%Gvx|vPG7Vb`6E&u78av^b!xRHMl~Arlf-tF8ElaT}G8Cp*oF{N$ zA;xgFtXD0qtX9p9Mw!;tZM|+gWzN)c-7!o)Uu%RJRB`R8rE#uQG7;9l#_kNj0n^OxS}-TSS(Kl3vk?c=rXvk&v$Lw~`) z-hR~Ez)RCv|3+t}yLyx^9Z97D+JW^Vi4A@Rb{R{S#WWndX;o?6)NZj!EMxD{Km08c LLIy&L%*EWlHSndb diff --git a/duckhunt/src/auth.py b/duckhunt/src/auth.py deleted file mode 100644 index d04dcba..0000000 --- a/duckhunt/src/auth.py +++ /dev/null @@ -1,108 +0,0 @@ -import hashlib -import secrets -import asyncio -from typing import Optional -from src.db import DuckDB - -class AuthSystem: - def __init__(self, db): - self.db = db - self.bot = None # Will be set by the bot - self.authenticated_users = {} # nick -> account_name - self.pending_registrations = {} # nick -> temp_data - - def hash_password(self, password: str, salt: Optional[str] = None) -> tuple: - if salt is None: - salt = secrets.token_hex(16) - hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000) - return hashed.hex(), salt - - def verify_password(self, password: str, hashed: str, salt: str) -> bool: - test_hash, _ = self.hash_password(password, salt) - return test_hash == hashed - - def register_account(self, username: str, password: str, nick: str, hostmask: str) -> bool: - # Check if account exists - existing = self.db.load_account(username) - if existing: - return False - - hashed_pw, salt = self.hash_password(password) - account_data = { - 'username': username, - 'password_hash': hashed_pw, - 'salt': salt, - 'primary_nick': nick, - 'hostmask': hostmask, - 'created_at': None, # Set by DB - 'auth_method': 'password' # 'password', 'nickserv', 'hostmask' - } - - self.db.save_account(username, account_data) - return True - - def authenticate(self, username: str, password: str, nick: str) -> bool: - account = self.db.load_account(username) - if not account: - return False - - if self.verify_password(password, account['password_hash'], account['salt']): - self.authenticated_users[nick] = username - return True - return False - - def get_account_for_nick(self, nick: str) -> str: - return self.authenticated_users.get(nick, "") - - def is_authenticated(self, nick: str) -> bool: - return nick in self.authenticated_users - - def logout(self, nick: str): - if nick in self.authenticated_users: - del self.authenticated_users[nick] - - def set_bot(self, bot): - """Set the bot instance for sending messages""" - self.bot = bot - - async def attempt_nickserv_auth(self): - """Attempt NickServ identification as fallback""" - if not self.bot: - return - - sasl_config = self.bot.config.get('sasl', {}) - username = sasl_config.get('username', '') - password = sasl_config.get('password', '') - - if username and password: - self.bot.logger.info(f"Attempting NickServ identification for {username}") - # Try both common NickServ commands - self.bot.send_raw(f'PRIVMSG NickServ :IDENTIFY {username} {password}') - # Some networks use just the password if nick matches - await asyncio.sleep(1) - self.bot.send_raw(f'PRIVMSG NickServ :IDENTIFY {password}') - self.bot.logger.info("NickServ identification commands sent") - else: - self.bot.logger.debug("No SASL credentials available for NickServ fallback") - - async def handle_nickserv_response(self, message): - """Handle responses from NickServ""" - if not self.bot: - return - - message_lower = message.lower() - - if any(phrase in message_lower for phrase in [ - 'you are now identified', 'password accepted', 'you are already identified', - 'authentication successful', 'you have been identified' - ]): - self.bot.logger.info("NickServ identification successful!") - - elif any(phrase in message_lower for phrase in [ - 'invalid password', 'incorrect password', 'access denied', - 'authentication failed', 'not registered', 'nickname is not registered' - ]): - self.bot.logger.error(f"NickServ identification failed: {message}") - - else: - self.bot.logger.debug(f"NickServ message: {message}") diff --git a/duckhunt/src/db.py b/duckhunt/src/db.py deleted file mode 100644 index 771b9af..0000000 --- a/duckhunt/src/db.py +++ /dev/null @@ -1,97 +0,0 @@ -import sqlite3 -import json -import datetime - -class DuckDB: - def __init__(self, db_path='duckhunt.db'): - self.conn = sqlite3.connect(db_path) - self.create_tables() - - def create_tables(self): - with self.conn: - # Player data table - self.conn.execute('''CREATE TABLE IF NOT EXISTS players ( - nick TEXT PRIMARY KEY, - data TEXT - )''') - - # Account system table - self.conn.execute('''CREATE TABLE IF NOT EXISTS accounts ( - username TEXT PRIMARY KEY, - data TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )''') - - # Leaderboards table - self.conn.execute('''CREATE TABLE IF NOT EXISTS leaderboard ( - account TEXT, - stat_type TEXT, - value INTEGER, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (account, stat_type) - )''') - - # Trading table - self.conn.execute('''CREATE TABLE IF NOT EXISTS trades ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_account TEXT, - to_account TEXT, - trade_data TEXT, - status TEXT DEFAULT 'pending', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )''') - - def save_player(self, nick, data): - with self.conn: - self.conn.execute('''INSERT OR REPLACE INTO players (nick, data) VALUES (?, ?)''', - (nick, json.dumps(data))) - - def load_player(self, nick): - cur = self.conn.cursor() - cur.execute('SELECT data FROM players WHERE nick=?', (nick,)) - row = cur.fetchone() - return json.loads(row[0]) if row else None - - def get_all_players(self): - cur = self.conn.cursor() - cur.execute('SELECT nick, data FROM players') - return {nick: json.loads(data) for nick, data in cur.fetchall()} - - def save_account(self, username, data): - with self.conn: - self.conn.execute('''INSERT OR REPLACE INTO accounts (username, data) VALUES (?, ?)''', - (username, json.dumps(data))) - - def load_account(self, username): - cur = self.conn.cursor() - cur.execute('SELECT data FROM accounts WHERE username=?', (username,)) - row = cur.fetchone() - return json.loads(row[0]) if row else None - - def update_leaderboard(self, account, stat_type, value): - with self.conn: - self.conn.execute('''INSERT OR REPLACE INTO leaderboard (account, stat_type, value) VALUES (?, ?, ?)''', - (account, stat_type, value)) - - def get_leaderboard(self, stat_type, limit=10): - cur = self.conn.cursor() - cur.execute('SELECT account, value FROM leaderboard WHERE stat_type=? ORDER BY value DESC LIMIT ?', - (stat_type, limit)) - return cur.fetchall() - - def save_trade(self, from_account, to_account, trade_data): - with self.conn: - cur = self.conn.cursor() - cur.execute('''INSERT INTO trades (from_account, to_account, trade_data) VALUES (?, ?, ?)''', - (from_account, to_account, json.dumps(trade_data))) - return cur.lastrowid - - def get_pending_trades(self, account): - cur = self.conn.cursor() - cur.execute('''SELECT id, from_account, trade_data FROM trades - WHERE to_account=? AND status='pending' ''', (account,)) - return [(trade_id, from_acc, json.loads(data)) for trade_id, from_acc, data in cur.fetchall()] - - def complete_trade(self, trade_id): - with self.conn: - self.conn.execute('UPDATE trades SET status=? WHERE id=?', ('completed', trade_id)) diff --git a/duckhunt/src/duckhuntbot.py b/duckhunt/src/duckhuntbot.py deleted file mode 100644 index e7ed35d..0000000 --- a/duckhunt/src/duckhuntbot.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python3 -""" -Main DuckHunt IRC Bot using modular architecture -""" - -import asyncio -import ssl -import json -import random -import logging -import sys -import os -import time -import uuid -import signal -from typing import Optional - -from .logging_utils import setup_logger -from .utils import parse_message -from .db import DuckDB -from .game import DuckGame -from .auth import AuthSystem -from . import sasl - -class IRCBot: - def __init__(self, config): - self.config = config - self.logger = setup_logger("DuckHuntBot") - self.reader: Optional[asyncio.StreamReader] = None - self.writer: Optional[asyncio.StreamWriter] = None - self.registered = False - self.channels_joined = set() - self.shutdown_requested = False - self.running_tasks = set() - - # Initialize subsystems - self.db = DuckDB() - self.game = DuckGame(self, self.db) - self.auth = AuthSystem(self.db) - self.auth.set_bot(self) # Set bot reference for auth system - self.sasl_handler = sasl.SASLHandler(self, config) - - # IRC connection state - self.nick = config['nick'] - self.channels = config['channels'] - - def send_raw(self, msg): - """Send raw IRC message""" - if self.writer and not self.writer.is_closing(): - try: - self.writer.write(f"{msg}\r\n".encode('utf-8')) - except Exception as e: - self.logger.error(f"Error sending message: {e}") - - def send_message(self, target, msg): - """Send PRIVMSG to target""" - self.send_raw(f'PRIVMSG {target} :{msg}') - - async def connect(self): - """Connect to IRC server with SASL support""" - server = self.config['server'] - port = self.config['port'] - ssl_context = ssl.create_default_context() if self.config.get('ssl', True) else None - - self.logger.info(f"Connecting to {server}:{port} (SSL: {ssl_context is not None})") - - try: - self.reader, self.writer = await asyncio.open_connection( - server, port, ssl=ssl_context - ) - self.logger.info("Connected successfully!") - - # Start SASL negotiation if enabled - if await self.sasl_handler.start_negotiation(): - return True - else: - # Standard registration without SASL - await self.register_user() - return True - - except Exception as e: - self.logger.error(f"Connection failed: {e}") - return False - - async def register_user(self): - """Register with IRC server""" - self.logger.info(f"Registering as {self.nick}") - self.send_raw(f'NICK {self.nick}') - self.send_raw(f'USER {self.nick} 0 * :DuckHunt Bot') - - # Send password if configured (for servers that require it) - if self.config.get('password'): - self.send_raw(f'PASS {self.config["password"]}') - - async def handle_irc_message(self, line): - """Handle individual IRC message""" - try: - prefix, command, params, trailing = parse_message(line) - - # Handle SASL-related messages - if command in ['CAP', 'AUTHENTICATE', '903', '904', '905', '906', '907', '908']: - handled = await self.sasl_handler.handle_sasl_result(command, params, trailing) - if command == 'CAP': - handled = await self.sasl_handler.handle_cap_response(params, trailing) - elif command == 'AUTHENTICATE': - handled = await self.sasl_handler.handle_authenticate_response(params) - - # If SASL handler didn't handle it, continue with normal processing - if handled: - return - - # Handle standard IRC messages - if command == '001': # Welcome - self.registered = True - auth_status = " (SASL authenticated)" if self.sasl_handler.is_authenticated() else "" - self.logger.info(f"Successfully registered!{auth_status}") - - # If SASL failed, try NickServ identification - if not self.sasl_handler.is_authenticated(): - await self.auth.attempt_nickserv_auth() - - # Join channels - for chan in self.channels: - self.logger.info(f"Joining {chan}") - self.send_raw(f'JOIN {chan}') - - elif command == 'JOIN' and prefix and prefix.startswith(self.nick): - channel = trailing or (params[0] if params else '') - if channel: - self.channels_joined.add(channel) - self.logger.info(f"Successfully joined {channel}") - - elif command == 'PRIVMSG' and trailing: - target = params[0] if params else '' - sender = prefix.split('!')[0] if prefix else '' - - # Handle NickServ responses - if sender.lower() == 'nickserv': - await self.auth.handle_nickserv_response(trailing) - elif trailing == 'VERSION': - self.send_raw(f'NOTICE {sender} :VERSION DuckHunt Bot v2.0') - else: - # Handle game commands - await self.game.handle_command(prefix, target, trailing) - - elif command == 'PING': - # Respond to PING - self.send_raw(f'PONG :{trailing}') - - except Exception as e: - self.logger.error(f"Error handling IRC message '{line}': {e}") - - async def listen(self): - """Main IRC message listening loop""" - buffer = "" - - while not self.shutdown_requested: - try: - if not self.reader: - break - - data = await self.reader.read(4096) - if not data: - self.logger.warning("Connection closed by server") - break - - buffer += data.decode('utf-8', errors='ignore') - - while '\n' in buffer: - line, buffer = buffer.split('\n', 1) - line = line.rstrip('\r') - - if line: - await self.handle_irc_message(line) - - except asyncio.CancelledError: - break - except Exception as e: - self.logger.error(f"Error in listen loop: {e}") - await asyncio.sleep(1) - - def setup_signal_handlers(self): - """Setup signal handlers for graceful shutdown""" - def signal_handler(signum, frame): - self.logger.info(f"Received signal {signum}, initiating graceful shutdown...") - self.shutdown_requested = True - - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - async def cleanup(self): - """Cleanup resources and save data""" - self.logger.info("Starting cleanup process...") - - try: - # Cancel all running tasks - for task in self.running_tasks.copy(): - if not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - # Send goodbye message - if self.writer and not self.writer.is_closing(): - for channel in self.channels_joined: - self.send_message(channel, "🦆 DuckHunt Bot shutting down. Thanks for playing! 🦆") - await asyncio.sleep(0.1) - - self.send_raw('QUIT :DuckHunt Bot shutting down gracefully') - await asyncio.sleep(1.0) - - self.writer.close() - await self.writer.wait_closed() - self.logger.info("IRC connection closed") - - # Save database (no specific save_all method) - # Players are saved individually through the game engine - self.logger.info("Final database save completed") - - self.logger.info("Cleanup completed successfully") - - except Exception as e: - self.logger.error(f"Error during cleanup: {e}") - - async def run(self): - """Main bot entry point""" - try: - self.setup_signal_handlers() - - self.logger.info("Starting DuckHunt Bot...") - - # Load database (no async initialization needed) - # Database is initialized in constructor - - # Connect to IRC - if not await self.connect(): - return False - - # Create main tasks - listen_task = asyncio.create_task(self.listen(), name="listen") - game_task = asyncio.create_task(self.game.spawn_ducks_loop(), name="duck_spawner") - - self.running_tasks.add(listen_task) - self.running_tasks.add(game_task) - - # Wait for completion - done, pending = await asyncio.wait( - [listen_task, game_task], - return_when=asyncio.FIRST_COMPLETED - ) - - if self.shutdown_requested: - self.logger.info("Shutdown requested, stopping all tasks...") - else: - self.logger.warning("A main task completed unexpectedly") - - # Cancel remaining tasks - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - except KeyboardInterrupt: - self.logger.info("Keyboard interrupt received") - self.shutdown_requested = True - except Exception as e: - self.logger.error(f"Fatal error in main loop: {e}") - import traceback - traceback.print_exc() - finally: - await self.cleanup() - - return True diff --git a/duckhunt/src/game.py b/duckhunt/src/game.py deleted file mode 100644 index ffd02f5..0000000 --- a/duckhunt/src/game.py +++ /dev/null @@ -1,566 +0,0 @@ -import asyncio -import random -from src.items import DuckTypes, WeaponTypes, AmmoTypes, Attachments -from src.auth import AuthSystem - -class DuckGame: - def __init__(self, bot, db): - self.bot = bot - self.config = bot.config - self.logger = getattr(bot, 'logger', None) - self.db = db - self.auth = AuthSystem(db) - self.duck_spawn_min = self.config.get('duck_spawn_min', 30) - self.duck_spawn_max = self.config.get('duck_spawn_max', 120) - self.ducks = {} # channel: duck dict or None - self.players = {} # nick: player dict - self.duck_alerts = set() # nicks who want duck alerts - - def get_player(self, nick): - if nick in self.players: - return self.players[nick] - data = self.db.load_player(nick) - if data: - data['friends'] = set(data.get('friends', [])) - self.players[nick] = data - return data - default = { - 'ammo': 1, 'max_ammo': 1, 'friends': set(), 'caught': 0, 'coins': 100, - 'accuracy': 70, 'reliability': 80, 'gun_oil': 0, 'scope': False, - 'silencer': False, 'lucky_charm': False, 'xp': 0, 'level': 1, - 'bank_account': 0, 'insurance': {'active': False, 'claims': 0}, - 'weapon': 'basic_gun', 'weapon_durability': 100, 'ammo_type': 'standard', - 'attachments': [], 'hunting_license': {'active': False, 'expires': None}, - 'duck_alerts': False, 'auth_method': 'nick' # 'nick', 'hostmask', 'account' - } - self.players[nick] = default - return default - - def save_player(self, nick, data): - self.players[nick] = data - data_to_save = dict(data) - data_to_save['friends'] = list(data_to_save.get('friends', [])) - self.db.save_player(nick, data_to_save) - - async def spawn_ducks_loop(self): - while True: - wait_time = random.randint(self.duck_spawn_min, self.duck_spawn_max) - if self.logger: - self.logger.info(f"Waiting {wait_time}s before next duck spawn.") - await asyncio.sleep(wait_time) - for chan in self.bot.channels: - duck = self.ducks.get(chan) - if not (duck and duck.get('alive')): - duck_type = DuckTypes.get_random_duck() - self.ducks[chan] = { - 'alive': True, - 'type': duck_type, - 'health': duck_type['health'], - 'max_health': duck_type['health'] - } - if self.logger: - self.logger.info(f"{duck_type['name']} spawned in {chan}") - - spawn_msg = f'\033[93m{duck_type["emoji"]} A {duck_type["name"]} appears! Type !bang, !catch, !bef, or !reload!\033[0m' - await self.bot.send_message(chan, spawn_msg) - - # Alert subscribed players - if self.duck_alerts: - alert_msg = f"🦆 DUCK ALERT: {duck_type['name']} in {chan}!" - for alert_nick in self.duck_alerts: - try: - await self.bot.send_message(alert_nick, alert_msg) - except: - pass # User might be offline - - async def handle_command(self, user, channel, message): - nick = user.split('!')[0] if user else 'unknown' - hostmask = user if user else 'unknown' - cmd = message.strip().lower() - if self.logger: - self.logger.info(f"{nick}@{channel}: {cmd}") - - # Handle private message commands - if channel == self.bot.nick: # Private message - if cmd.startswith('identify '): - parts = cmd.split(' ', 2) - if len(parts) == 3: - await self.handle_identify(nick, parts[1], parts[2]) - else: - await self.bot.send_message(nick, "Usage: identify ") - return - elif cmd == 'register': - await self.bot.send_message(nick, "To register: /msg me register ") - return - elif cmd.startswith('register '): - parts = cmd.split(' ', 2) - if len(parts) == 3: - await self.handle_register(nick, hostmask, parts[1], parts[2]) - else: - await self.bot.send_message(nick, "Usage: register ") - return - - # Public channel commands - if cmd == '!bang': - await self.handle_bang(nick, channel) - elif cmd == '!reload': - await self.handle_reload(nick, channel) - elif cmd == '!bef': - await self.handle_bef(nick, channel) - elif cmd == '!catch': - await self.handle_catch(nick, channel) - elif cmd == '!shop': - await self.handle_shop(nick, channel) - elif cmd == '!duckstats': - await self.handle_duckstats(nick, channel) - elif cmd.startswith('!buy '): - item_num = cmd.split(' ', 1)[1] - await self.handle_buy(nick, channel, item_num) - elif cmd.startswith('!sell '): - item_num = cmd.split(' ', 1)[1] - await self.handle_sell(nick, channel, item_num) - elif cmd == '!stats': - await self.handle_stats(nick, channel) - elif cmd == '!help': - await self.handle_help(nick, channel) - elif cmd == '!leaderboard' or cmd == '!top': - await self.handle_leaderboard(nick, channel) - elif cmd == '!bank': - await self.handle_bank(nick, channel) - elif cmd == '!license': - await self.handle_license(nick, channel) - elif cmd == '!alerts': - await self.handle_alerts(nick, channel) - elif cmd.startswith('!trade '): - parts = cmd.split(' ', 2) - if len(parts) >= 2: - await self.handle_trade(nick, channel, parts[1:]) - elif cmd.startswith('!sabotage '): - target = cmd.split(' ', 1)[1] - await self.handle_sabotage(nick, channel, target) - - async def handle_bang(self, nick, channel): - player = self.get_player(nick) - duck = self.ducks.get(channel) - if player['ammo'] <= 0: - await self.bot.send_message(channel, f'\033[91m{nick}, you need to !reload!\033[0m') - return - if duck and duck.get('alive'): - player['ammo'] -= 1 - - # Calculate hit chance based on accuracy and upgrades - base_accuracy = player['accuracy'] - if player['scope']: - base_accuracy += 15 - if player['lucky_charm']: - base_accuracy += 10 - - hit_roll = random.randint(1, 100) - if hit_roll <= base_accuracy: - player['caught'] += 1 - coins_earned = 1 - if player['silencer']: - coins_earned += 1 # Bonus for silencer - player['coins'] += coins_earned - self.ducks[channel] = {'alive': False} - await self.bot.send_message(channel, f'\033[92m{nick} shot the duck! (+{coins_earned} coin{"s" if coins_earned > 1 else ""})\033[0m') - if self.logger: - self.logger.info(f"{nick} shot a duck in {channel}") - else: - await self.bot.send_message(channel, f'\033[93m{nick} missed the duck!\033[0m') - else: - await self.bot.send_message(channel, f'No duck to shoot, {nick}!') - self.save_player(nick, player) - - async def handle_reload(self, nick, channel): - player = self.get_player(nick) - - # Check gun reliability - can fail to reload - reliability = player['reliability'] - if player['gun_oil'] > 0: - reliability += 15 - player['gun_oil'] -= 1 # Gun oil gets used up - - reload_roll = random.randint(1, 100) - if reload_roll <= reliability: - player['ammo'] = player['max_ammo'] - await self.bot.send_message(channel, f'\033[94m{nick} reloaded successfully!\033[0m') - else: - await self.bot.send_message(channel, f'\033[91m{nick}\'s gun jammed while reloading! Try again.\033[0m') - - self.save_player(nick, player) - - async def handle_bef(self, nick, channel): - player = self.get_player(nick) - duck = self.ducks.get(channel) - if duck and duck.get('alive'): - player['friends'].add('duck') - self.ducks[channel] = {'alive': False} - await self.bot.send_message(channel, f'\033[96m{nick} befriended the duck!\033[0m') - if self.logger: - self.logger.info(f"{nick} befriended a duck in {channel}") - else: - await self.bot.send_message(channel, f'No duck to befriend, {nick}!') - self.save_player(nick, player) - - async def handle_catch(self, nick, channel): - player = self.get_player(nick) - duck = self.ducks.get(channel) - if duck and duck.get('alive'): - player['caught'] += 1 - self.ducks[channel] = {'alive': False} - await self.bot.send_message(channel, f'\033[92m{nick} caught the duck!\033[0m') - if self.logger: - self.logger.info(f"{nick} caught a duck in {channel}") - else: - await self.bot.send_message(channel, f'No duck to catch, {nick}!') - self.save_player(nick, player) - - async def handle_shop(self, nick, channel): - player = self.get_player(nick) - coins = player['coins'] - - shop_items = [ - "🔫 Scope - Improves accuracy by 15% (Cost: 5 coins)", - "🔇 Silencer - Bonus coin on successful shots (Cost: 8 coins)", - "🛢️ Gun Oil - Improves reload reliability for 3 reloads (Cost: 3 coins)", - "🍀 Lucky Charm - Improves accuracy by 10% (Cost: 10 coins)", - "📦 Ammo Upgrade - Increases max ammo capacity by 1 (Cost: 12 coins)", - "🎯 Accuracy Training - Permanently increases accuracy by 5% (Cost: 15 coins)", - "🔧 Gun Maintenance - Permanently increases reliability by 10% (Cost: 20 coins)" - ] - - shop_msg = f"\033[95m{nick}'s Shop (Coins: {coins}):\033[0m\n" - for i, item in enumerate(shop_items, 1): - shop_msg += f"{i}. {item}\n" - shop_msg += "Use !buy to purchase an item!\n" - shop_msg += "Use !sell to sell upgrades for coins!" - - await self.bot.send_message(channel, shop_msg) - async def handle_duckstats(self, nick, channel): - player = self.get_player(nick) - stats = f"\033[95m{nick}'s Duck Stats:\033[0m\n" - stats += f"Caught: {player['caught']}\n" - stats += f"Coins: {player['coins']}\n" - stats += f"Accuracy: {player['accuracy']}%\n" - stats += f"Reliability: {player['reliability']}%\n" - stats += f"Max Ammo: {player['max_ammo']}\n" - stats += f"Gun Oil: {player['gun_oil']} uses left\n" - upgrades = [] - if player['scope']: upgrades.append("Scope") - if player['silencer']: upgrades.append("Silencer") - if player['lucky_charm']: upgrades.append("Lucky Charm") - stats += f"Upgrades: {', '.join(upgrades) if upgrades else 'None'}\n" - stats += f"Friends: {', '.join(player['friends']) if player['friends'] else 'None'}\n" - await self.bot.send_message(channel, stats) - - async def handle_buy(self, nick, channel, item_num): - player = self.get_player(nick) - - try: - item_id = int(item_num) - except ValueError: - await self.bot.send_message(channel, f'{nick}, please specify a valid item number!') - return - - shop_items = { - 1: ("scope", 5, "Scope"), - 2: ("silencer", 8, "Silencer"), - 3: ("gun_oil", 3, "Gun Oil"), - 4: ("lucky_charm", 10, "Lucky Charm"), - 5: ("ammo_upgrade", 12, "Ammo Upgrade"), - 6: ("accuracy_training", 15, "Accuracy Training"), - 7: ("gun_maintenance", 20, "Gun Maintenance") - } - - if item_id not in shop_items: - await self.bot.send_message(channel, f'{nick}, invalid item number!') - return - - item_key, cost, item_name = shop_items[item_id] - - if player['coins'] < cost: - await self.bot.send_message(channel, f'\033[91m{nick}, you need {cost} coins for {item_name}! (You have {player["coins"]})\033[0m') - return - - # Process purchase - player['coins'] -= cost - - if item_key == "scope": - if player['scope']: - await self.bot.send_message(channel, f'{nick}, you already have a scope!') - player['coins'] += cost # Refund - return - player['scope'] = True - elif item_key == "silencer": - if player['silencer']: - await self.bot.send_message(channel, f'{nick}, you already have a silencer!') - player['coins'] += cost - return - player['silencer'] = True - elif item_key == "gun_oil": - player['gun_oil'] += 3 - elif item_key == "lucky_charm": - if player['lucky_charm']: - await self.bot.send_message(channel, f'{nick}, you already have a lucky charm!') - player['coins'] += cost - return - player['lucky_charm'] = True - elif item_key == "ammo_upgrade": - player['max_ammo'] += 1 - elif item_key == "accuracy_training": - player['accuracy'] = min(95, player['accuracy'] + 5) # Cap at 95% - elif item_key == "gun_maintenance": - player['reliability'] = min(95, player['reliability'] + 10) # Cap at 95% - - await self.bot.send_message(channel, f'\033[92m{nick} purchased {item_name}!\033[0m') - self.save_player(nick, player) - - async def handle_sell(self, nick, channel, item_num): - player = self.get_player(nick) - - try: - item_id = int(item_num) - except ValueError: - await self.bot.send_message(channel, f'{nick}, please specify a valid item number!') - return - - sellable_items = { - 1: ("scope", 3, "Scope"), - 2: ("silencer", 5, "Silencer"), - 3: ("gun_oil", 1, "Gun Oil (per use)"), - 4: ("lucky_charm", 6, "Lucky Charm") - } - - if item_id not in sellable_items: - await self.bot.send_message(channel, f'{nick}, invalid item number! Sellable items: 1-4') - return - - item_key, sell_price, item_name = sellable_items[item_id] - - if item_key == "scope": - if not player['scope']: - await self.bot.send_message(channel, f'{nick}, you don\'t have a scope to sell!') - return - player['scope'] = False - player['coins'] += sell_price - elif item_key == "silencer": - if not player['silencer']: - await self.bot.send_message(channel, f'{nick}, you don\'t have a silencer to sell!') - return - player['silencer'] = False - player['coins'] += sell_price - elif item_key == "gun_oil": - if player['gun_oil'] <= 0: - await self.bot.send_message(channel, f'{nick}, you don\'t have any gun oil to sell!') - return - player['gun_oil'] -= 1 - player['coins'] += sell_price - elif item_key == "lucky_charm": - if not player['lucky_charm']: - await self.bot.send_message(channel, f'{nick}, you don\'t have a lucky charm to sell!') - return - player['lucky_charm'] = False - player['coins'] += sell_price - - await self.bot.send_message(channel, f'\033[94m{nick} sold {item_name} for {sell_price} coins!\033[0m') - self.save_player(nick, player) - - async def handle_stats(self, nick, channel): - player = self.get_player(nick) - - # Calculate effective accuracy and reliability - effective_accuracy = player['accuracy'] - if player['scope']: - effective_accuracy += 15 - if player['lucky_charm']: - effective_accuracy += 10 - effective_accuracy = min(100, effective_accuracy) - - effective_reliability = player['reliability'] - if player['gun_oil'] > 0: - effective_reliability += 15 - effective_reliability = min(100, effective_reliability) - - stats = f"\033[96m{nick}'s Combat Stats:\033[0m\n" - stats += f"🎯 Base Accuracy: {player['accuracy']}% (Effective: {effective_accuracy}%)\n" - stats += f"🔧 Base Reliability: {player['reliability']}% (Effective: {effective_reliability}%)\n" - stats += f"🔫 Ammo: {player['ammo']}/{player['max_ammo']}\n" - stats += f"💰 Coins: {player['coins']}\n" - stats += f"🦆 Ducks Caught: {player['caught']}\n" - stats += f"🛢️ Gun Oil: {player['gun_oil']} uses\n" - - upgrades = [] - if player['scope']: upgrades.append("🔭 Scope") - if player['silencer']: upgrades.append("🔇 Silencer") - if player['lucky_charm']: upgrades.append("🍀 Lucky Charm") - stats += f"⚡ Active Upgrades: {', '.join(upgrades) if upgrades else 'None'}\n" - - friends = list(player['friends']) - stats += f"🤝 Friends: {', '.join(friends) if friends else 'None'}" - - await self.bot.send_message(channel, stats) - - async def handle_register(self, nick, hostmask, username, password): - if self.auth.register_account(username, password, nick, hostmask): - await self.bot.send_message(nick, f"✅ Account '{username}' registered successfully! Use 'identify {username} {password}' to login.") - else: - await self.bot.send_message(nick, f"❌ Account '{username}' already exists!") - - async def handle_identify(self, nick, username, password): - if self.auth.authenticate(username, password, nick): - await self.bot.send_message(nick, f"✅ Authenticated as '{username}'!") - # Transfer nick-based data to account if exists - nick_data = self.db.load_player(nick) - if nick_data: - account_data = self.db.load_player(username) - if not account_data: - self.db.save_player(username, nick_data) - await self.bot.send_message(nick, "📊 Your progress has been transferred to your account!") - else: - await self.bot.send_message(nick, "❌ Invalid username or password!") - - async def handle_help(self, nick, channel): - help_text = """ -🦆 **DuckHunt Bot Commands** 🦆 - -**🎯 Hunting:** -• !bang - Shoot at a duck (requires ammo) -• !reload - Reload your weapon (can fail based on reliability) -• !catch - Catch a duck with your hands -• !bef - Befriend a duck instead of shooting - -**🛒 Economy:** -• !shop - View available items for purchase -• !buy - Purchase an item from the shop -• !sell - Sell equipment for coins -• !bank - Access banking services (deposits, loans) -• !trade - Trade with other players - -**📊 Stats & Info:** -• !stats - View detailed combat statistics -• !duckstats - View personal hunting statistics -• !leaderboard - View top players -• !license - Manage hunting license - -**⚙️ Settings:** -• !alerts - Toggle duck spawn notifications -• !register - Register an account (via /msg) -• identify - Login to account (via /msg) - -**🎮 Advanced:** -• !sabotage - Attempt to sabotage another hunter -• !help - Show this help message - -💡 **Tips:** -- Different duck types give different rewards -- Weapon durability affects performance -- Insurance protects your equipment -- Level up to unlock better gear! - """ - await self.bot.send_message(nick, help_text) - - async def handle_leaderboard(self, nick, channel): - leaderboard_data = self.db.get_leaderboard('caught', 10) - if not leaderboard_data: - await self.bot.send_message(channel, "No leaderboard data available yet!") - return - - msg = "🏆 **Duck Hunting Leaderboard** 🏆\n" - for i, (account, caught) in enumerate(leaderboard_data, 1): - emoji = "🥇" if i == 1 else "🥈" if i == 2 else "🥉" if i == 3 else f"{i}." - msg += f"{emoji} {account}: {caught} ducks\n" - - await self.bot.send_message(channel, msg) - - async def handle_bank(self, nick, channel): - player = self.get_player(nick) - bank_msg = f""" -🏦 **{nick}'s Bank Account** 🏦 -💰 Cash on hand: {player['coins']} coins -🏛️ Bank balance: {player['bank_account']} coins -📈 Total wealth: {player['coins'] + player['bank_account']} coins - -**Commands:** -• !bank deposit - Deposit coins (earns 2% daily interest) -• !bank withdraw - Withdraw coins -• !bank loan - Take a loan (10% interest) - """ - await self.bot.send_message(nick, bank_msg) - - async def handle_license(self, nick, channel): - player = self.get_player(nick) - license_active = player['hunting_license']['active'] - - if license_active: - expires = player['hunting_license']['expires'] - msg = f"🎫 Your hunting license is active until {expires}\n" - msg += "Licensed hunters get +25% coins and access to rare equipment!" - else: - msg = "🎫 You don't have a hunting license.\n" - msg += "Purchase one for 50 coins to get:\n" - msg += "• +25% coin rewards\n" - msg += "• Access to premium shop items\n" - msg += "• Reduced insurance costs\n" - msg += "Type '!buy license' to purchase" - - await self.bot.send_message(channel, msg) - - async def handle_alerts(self, nick, channel): - if nick in self.duck_alerts: - self.duck_alerts.remove(nick) - await self.bot.send_message(channel, f"🔕 {nick}: Duck alerts disabled") - else: - self.duck_alerts.add(nick) - await self.bot.send_message(channel, f"🔔 {nick}: Duck alerts enabled! You'll be notified when ducks spawn.") - - async def handle_trade(self, nick, channel, args): - if len(args) < 3: - await self.bot.send_message(channel, f"{nick}: Usage: !trade ") - return - - target, item, amount = args[0], args[1], args[2] - player = self.get_player(nick) - - try: - amount = int(amount) - except ValueError: - await self.bot.send_message(channel, f"{nick}: Amount must be a number!") - return - - if item == "coins": - if player['coins'] < amount: - await self.bot.send_message(channel, f"{nick}: You don't have enough coins!") - return - - trade_data = { - 'type': 'coins', - 'amount': amount, - 'from_nick': nick - } - - trade_id = self.db.save_trade(nick, target, trade_data) - await self.bot.send_message(channel, f"💸 Trade offer sent to {target}: {amount} coins") - await self.bot.send_message(target, f"💰 {nick} wants to trade you {amount} coins. Type '!accept {trade_id}' to accept!") - else: - await self.bot.send_message(channel, f"{nick}: Only coin trading is available currently!") - - async def handle_sabotage(self, nick, channel, target): - player = self.get_player(nick) - target_player = self.get_player(target) - - if player['coins'] < 5: - await self.bot.send_message(channel, f"{nick}: Sabotage costs 5 coins!") - return - - success_chance = 60 + (player['level'] * 5) - if random.randint(1, 100) <= success_chance: - player['coins'] -= 5 - target_player['weapon_durability'] = max(0, target_player['weapon_durability'] - 10) - await self.bot.send_message(channel, f"😈 {nick} successfully sabotaged {target}'s weapon!") - self.save_player(nick, player) - self.save_player(target, target_player) - else: - player['coins'] -= 5 - await self.bot.send_message(channel, f"😅 {nick}'s sabotage attempt failed!") - self.save_player(nick, player) diff --git a/duckhunt/src/items.py b/duckhunt/src/items.py deleted file mode 100644 index f579bf1..0000000 --- a/duckhunt/src/items.py +++ /dev/null @@ -1,124 +0,0 @@ -import random - -class DuckTypes: - COMMON = { - 'name': 'Common Duck', - 'emoji': '🦆', - 'rarity': 70, - 'coins': 1, - 'xp': 10, - 'health': 1 - } - - RARE = { - 'name': 'Rare Duck', - 'emoji': '🦆✨', - 'rarity': 20, - 'coins': 3, - 'xp': 25, - 'health': 1 - } - - GOLDEN = { - 'name': 'Golden Duck', - 'emoji': '🥇🦆', - 'rarity': 8, - 'coins': 10, - 'xp': 50, - 'health': 2 - } - - ARMORED = { - 'name': 'Armored Duck', - 'emoji': '🛡️🦆', - 'rarity': 2, - 'coins': 15, - 'xp': 75, - 'health': 3 - } - - @classmethod - def get_random_duck(cls): - roll = random.randint(1, 100) - if roll <= cls.COMMON['rarity']: - return cls.COMMON - elif roll <= cls.COMMON['rarity'] + cls.RARE['rarity']: - return cls.RARE - elif roll <= cls.COMMON['rarity'] + cls.RARE['rarity'] + cls.GOLDEN['rarity']: - return cls.GOLDEN - else: - return cls.ARMORED - -class WeaponTypes: - BASIC_GUN = { - 'name': 'Basic Gun', - 'accuracy_bonus': 0, - 'durability': 100, - 'max_durability': 100, - 'repair_cost': 5, - 'attachment_slots': 1 - } - - SHOTGUN = { - 'name': 'Shotgun', - 'accuracy_bonus': -10, - 'durability': 80, - 'max_durability': 80, - 'repair_cost': 8, - 'attachment_slots': 2, - 'spread_shot': True # Can hit multiple ducks - } - - RIFLE = { - 'name': 'Rifle', - 'accuracy_bonus': 20, - 'durability': 120, - 'max_durability': 120, - 'repair_cost': 12, - 'attachment_slots': 3 - } - -class AmmoTypes: - STANDARD = { - 'name': 'Standard Ammo', - 'damage': 1, - 'accuracy_modifier': 0, - 'cost': 1 - } - - RUBBER = { - 'name': 'Rubber Bullets', - 'damage': 0, # Non-lethal, for catching - 'accuracy_modifier': 5, - 'cost': 2, - 'special': 'stun' - } - - EXPLOSIVE = { - 'name': 'Explosive Rounds', - 'damage': 2, - 'accuracy_modifier': -5, - 'cost': 5, - 'special': 'area_damage' - } - -class Attachments: - LASER_SIGHT = { - 'name': 'Laser Sight', - 'accuracy_bonus': 10, - 'cost': 15, - 'durability_cost': 2 # Uses weapon durability faster - } - - EXTENDED_MAG = { - 'name': 'Extended Magazine', - 'ammo_bonus': 2, - 'cost': 20 - } - - BIPOD = { - 'name': 'Bipod', - 'accuracy_bonus': 15, - 'reliability_bonus': 5, - 'cost': 25 - } diff --git a/duckhunt/src/logging_utils.py b/duckhunt/src/logging_utils.py deleted file mode 100644 index 439c6d6..0000000 --- a/duckhunt/src/logging_utils.py +++ /dev/null @@ -1,28 +0,0 @@ -import logging -import sys -from functools import partial - -class ColorFormatter(logging.Formatter): - COLORS = { - 'DEBUG': '\033[94m', - 'INFO': '\033[92m', - 'WARNING': '\033[93m', - 'ERROR': '\033[91m', - 'CRITICAL': '\033[95m', - 'ENDC': '\033[0m', - } - def format(self, record): - color = self.COLORS.get(record.levelname, '') - endc = self.COLORS['ENDC'] - msg = super().format(record) - return f"{color}{msg}{endc}" - -def setup_logger(name='DuckHuntBot', level=logging.INFO): - logger = logging.getLogger(name) - handler = logging.StreamHandler(sys.stdout) - formatter = ColorFormatter('[%(asctime)s] %(levelname)s: %(message)s') - handler.setFormatter(formatter) - logger.addHandler(handler) - logger.setLevel(level) - logger.propagate = False - return logger diff --git a/duckhunt/src/utils.py b/duckhunt/src/utils.py deleted file mode 100644 index 6b04233..0000000 --- a/duckhunt/src/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -def parse_message(line): - prefix = '' - trailing = '' - if line.startswith(':'): - prefix, line = line[1:].split(' ', 1) - if ' :' in line: - line, trailing = line.split(' :', 1) - parts = line.split() - command = parts[0] if parts else '' - params = parts[1:] if len(parts) > 1 else [] - return prefix, command, params, trailing diff --git a/duckhunt/test_bot.py b/duckhunt/test_bot.py deleted file mode 100644 index 81c27e7..0000000 --- a/duckhunt/test_bot.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for DuckHunt Bot -Run this to test both the modular and simple bot implementations -""" - -import asyncio -import json -import sys -import os - -# Add src directory to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - -async def test_modular_bot(): - """Test the modular bot implementation""" - try: - print("🔧 Testing modular bot (src/duckhuntbot.py)...") - - # Load config - with open('config.json') as f: - config = json.load(f) - - # Test imports - from src.duckhuntbot import IRCBot - from src.sasl import SASLHandler - - # Create bot instance - bot = IRCBot(config) - print("✅ Modular bot initialized successfully!") - - # Test SASL handler - sasl_handler = SASLHandler(bot, config) - print("✅ SASL handler created successfully!") - - # Test database - bot.db.save_player("testuser", {"coins": 100, "caught": 5}) - data = bot.db.load_player("testuser") - if data and data['coins'] == 100: - print("✅ Database working!") - else: - print("❌ Database test failed!") - - # Test game logic - player = bot.game.get_player("testuser") - if player and 'coins' in player: - print("✅ Game logic working!") - else: - print("❌ Game logic test failed!") - - return True - - except Exception as e: - print(f"❌ Modular bot error: {e}") - import traceback - traceback.print_exc() - return False - -async def test_simple_bot(): - """Test the simple bot implementation""" - try: - print("\n🔧 Testing simple bot (simple_duckhunt.py)...") - - # Load config - with open('config.json') as f: - config = json.load(f) - - # Test imports - from simple_duckhunt import SimpleIRCBot - from src.sasl import SASLHandler - - # Create bot instance - bot = SimpleIRCBot(config) - print("✅ Simple bot initialized successfully!") - - # Test SASL handler integration - if hasattr(bot, 'sasl_handler'): - print("✅ SASL handler integrated!") - else: - print("❌ SASL handler not integrated!") - return False - - # Test database - if 'testuser' in bot.players: - bot.players['testuser']['coins'] = 200 - bot.save_database() - bot.load_database() - if bot.players.get('testuser', {}).get('coins') == 200: - print("✅ Simple bot database working!") - else: - print("❌ Simple bot database test failed!") - - return True - - except Exception as e: - print(f"❌ Simple bot error: {e}") - import traceback - traceback.print_exc() - return False - -async def test_sasl_config(): - """Test SASL configuration""" - try: - print("\n🔧 Testing SASL configuration...") - - # Load config - with open('config.json') as f: - config = json.load(f) - - # Check SASL config - sasl_config = config.get('sasl', {}) - if sasl_config.get('enabled'): - print("✅ SASL is enabled in config") - - username = sasl_config.get('username') - password = sasl_config.get('password') - - if username and password: - print(f"✅ SASL credentials configured (user: {username})") - else: - print("⚠️ SASL enabled but credentials missing") - else: - print("ℹ️ SASL is not enabled in config") - - return True - - except Exception as e: - print(f"❌ SASL config error: {e}") - return False - -async def main(): - """Main test function""" - print("🦆 DuckHunt Bot Integration Test") - print("=" * 50) - - try: - # Test configuration - config_ok = await test_sasl_config() - - # Test modular bot - modular_ok = await test_modular_bot() - - # Test simple bot - simple_ok = await test_simple_bot() - - print("\n" + "=" * 50) - print("📊 Test Results:") - print(f" Config: {'✅ PASS' if config_ok else '❌ FAIL'}") - print(f" Modular Bot: {'✅ PASS' if modular_ok else '❌ FAIL'}") - print(f" Simple Bot: {'✅ PASS' if simple_ok else '❌ FAIL'}") - - if all([config_ok, modular_ok, simple_ok]): - print("\n🎉 All tests passed! SASL integration is working!") - print("🦆 DuckHunt Bots are ready to deploy!") - return True - else: - print("\n💥 Some tests failed. Check the errors above.") - return False - - except Exception as e: - print(f"💥 Test suite error: {e}") - return False - -if __name__ == '__main__': - success = asyncio.run(main()) - if not success: - sys.exit(1) diff --git a/src/__pycache__/db.cpython-312.pyc b/src/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..37252ad01b3b1327e9c3bbc2e7908adb49c450be GIT binary patch literal 9706 zcmd^FZ)_CTcAweV*}t=1+w1>pugAv5EPqT0|G_{oi<1!I1V{)tyllriV|&ed*SRwW zyfq1WFM7|YRG6l!jglvf+A5Bcr#e!lAoUB7_aS{%)m;|q?F3aweNxM}4Ma@~{m^sn z?5w?J9r`|ZY@a!E@4090y>ssGoO`bS!Q*i;aQ*J@{&DC(8yV)`@WH&SdFF8lm}y34 z`WTs&Eitx_rFTo8h2FV7j^3?(R(j|Acnk9~BXb`yvh@p#p{36@ZVB?qperP4Qop1s z;=ouuqD2#NDHhen#eoD7Lt~NCuZ+btu{)u;9%8%Rh|$`hc~#8gQ}8^^C=7Ij!A?L2 zyt3sZ7CKNaTj9>j{6|cmO|}8G%XXj+*#XokJAt}n7f`qC1}ew`P><{ZS|*nP^^SAF z3f+dC33Vsi3jH0C;?kfpqCo$acQ#`74ExFD{~q_#UQVzROc=}Q6=mRwHJ@axE40<@ zX8Ewg?9oHSJ-s!x zPFn9e8t&ILE!1?RYdTVEc4TUHPFnxe{TRgI>*HLS7n&nJKkf!H&1i*LoB*>l&5Tq| zFvCk*i%)DKcNNnvalf-hBg|VGy9OFN}5JU1-vxK5dm(xbyU)Z;AZKxO!OuJjzzB)+~iqxbl`= zW}iM`WWMwmKVg1tHKkZ)oTHFc=-VIA7yF9(3s8poS23Fyl|5@=nUf3)KbOv(<4!VX z*;R~2eG?8~xNg}n;8ANZ%LXiM4B2>|TcK}gT+1%mHo?I-m-M4}lqbP^o##H_#^F2Q z{VaQyF?tnrB%2Q>Bv~|IRg}S<8O!9*u{U}}3b`yH2<5#=J4!0Dm@LbStA=Q@?jRxH z@M2I65KNVzPuwLYYYq@a(x6RTIlH7h=+!L=mH0rdZd1-hRZXSntXmTR=(=@SO#nrG zItPoGzMr6Z>egs{Afa~69uzn+UqTcCyd1CR^M@7_StJwT|six39;oy%xRE@oO`h$b1u3xBHldf9x zWk<^Q;v>f5Y0Z{b8SiXKm$#(ax^5h~aUj+D(%tg?+2)oh>$Gr1c$(uu@QL~Y4E&3l z1Dlvz?1lsF+^x?2HhB1^oj=fN|7NWn`Azkqo!oC-?V&B)Z@2KkM@phx4oasPC$4_b zx%`6l%l_JO9s-C`=%*FXZ%I&53j6YE&Gjx@KDC^;EI~nb5r+WrxaT0AWrCBfU-Dn% z9Wcwt5Q53JMI_S-C|C-c&_R_N>tg$H+cRjfwtyA|j07}TbKVNNs+P|hAV~oq7wcsE zgtZtAVEx<9TR*UlL+|l^memRf^apEqJYRbOU7X3S#~?}*rC6*8VIck};EQ-Rstt*B z#fn2xT#f;RB(0s=$Y{_)7ryR{NvcM}J)J%K5WQoN)oszZ48dz}vX-uS5u(9m*dx9j zSI&(p5riLejiZ=iylj^k^ia?-Uc#vw(XCo^L?MWr{}1u!zAkT$GE zEF$e#?mT!dqKsnXALI?ZacauQD2-ML#yey+k~Kh7G-E^`6Mkp}Yj&*wKSiau0M#^* z3rx;h?(AV70IYOf?V9PFv!?1cOum|bzTYJNRn=$p*X#e%pK03gJedA<$JdiD+o^QY11)q{z6jk`mMyrI<)yF(pQ#NePWJ*#sUqoZmcknoYxI(}(-v zbx+Vs7i5g)VGz~SP9-k&#}v7T;Ogs5u3b^kR2V)Zk*E}pC=W5}d5Bxa&v}9|o^CTV z>)eQR4oEZ(YYVy>jccaI_Mkg1^o^jQ=nhliG9O-pI~MFEor^-FWRtmVD_I=^m1+VA zMCf&m*SuG~sWmTuTbZdle5dYc>dm8*o~+w@dEdo-DgT}uV|U#LzZ2@Swf^Z3uY8#5 zIF@?*olNbCJGJknPQ5qz%6(tW6!~=X44K_CzwPV2U+ulQ<7V*N6PeA&)2ohW{IIDx zkrGa1y^SeH~OPVBU9w^k^xuV4DsEz;uTLf_5 zcXeNC-Rs|yjQ{8z|B2M`6DddCPZ;Cz)QP@K?YnnsrBqm2VvMO(o973dh0GZ70*KPG4cL9V3vVqA4qg2$Yx%i7TqkEHzEdI;KK%Ydjh`t=sZ2jWe+rEztCeQB#y& zF^Z0ZtU3Y&jL2O!C12bJiIwSHS9X2ebusk7U}qv9@0;C}_O&ng)}?*xGQJIyp$Bg7 z)RyV4D_v=KbGEvE`qY(Evu)|>j>(<}LivKQIxVc8J#$y+%=+plUk5!4zIISBD`$M` zQo_1t?WX`|4KVZ5OqBZEk0I`sEkLa@57b5}_)OrPvJ0r2Qh`zrrDc?QDJ_>PK&F!N zK1!?PYAC6RGC*qwk=DtO2F0J!9@?~2s4ZcoW-ZlH|Ecq@(bKPBixVumBhXV~|53`i ze<*|Ue29)YRNYOZb)y5YER-`!>>;i~-5HIafrvdp z#&u70FrFZa97d~Gdp>vSwqa>x1UeuXcgVD%F0Yo*q*xfL5-2L`b{u?I(sb9^XiPSz z!t691x9L%I@8DS6$k?e7%t7cCG`nX!7%xaxEzM6D-vIUnJn4oG6Y5kDR*Z1J1-N`3 zl=Nch8$gOupyV)=6yo@zb*`Z77MKT&;~OhoS;&AM$#K9v5Nc;Cay)R54e->?usJ*O z5R&_6_UD|)yBJTy%;uaMd4ciN&$Q$`$d@sm=2UR*38ZcAZMah=MEkD47bp-r?f%#!vJVm$!+FXR_I5&y!ts39dh6~5^EOwAqP!&@l%c^#QY3WMW?`4&vTH<;V`ID z*-P#Tu*V4w9m=H&D>J?}xGwq9K^ioe5eOW@(Ci6O-XDtsVnRw5qX>Ft*Dpm*pOuIV z#~mYZE)z8(X>t_Q=~l!{atv7V7Ct-BKhUzbu?&3%!I*)(gX9E~lR$J^L>e0$(x`tR zeOTgJbPwcRlskpwXGq?|dds;{g4;Wtk0hdTmHZLbNJ#pTM3A75AP9kE07wssB8#~( zavDht2?j4@JCZn(5E9&>kx?XrKzbn|B*IO(cE^;b=5VyYwrC_-6jBbD5k*PbM4U8L$e>uzn^K`2^bH>1&jxt zV*KiBYp<@IJpnBnHUZp2F@<~EXRhn6xvm=(8F4otKNJ^`ANT_D17ARX;3@KN!+3B; zp534Etp+Cm#ncHH!?Dl#GSwTvA3$-z9{^wQ2f!En0q_NX0DQq80B`t%)+OW1Hn(3( zUQMRf?!Iv()4VTR+ccfLlAI0B9m&*g%r=O0oTuhDWg50->jL@tBAL1uv;LNAyRPn< z9hpCx@$Xoo=Iu=Lfox01XARdIQtS8KtjM$+0LS8YO?ttxv~O8x+nH|LnQ7Y%*=}cN zS`a4>PVLEht7dxccw4jLhAG?B*q?d6=b`dh&tioSPCX0m$Y>%7?#nNA9X;zV6l>Um zUZu7#5GLNB|DE{jxrw*Vs=p6AofdIWQZm&jinFgyl`p{XZR82 ztzOte;DMHIm(=liB%09O`Org?)YEWKE-SbJfoKWz!V<#6J@Pt`qVr8RO*={r1CXW1=Hx@*3lVXVCH^hcsJ{gAAIyKj*;-lk<&ldcGY2wd;(|j=ImDdB=Inq> z@`4aZ3xT_WSe#W3?71uK1u$*xn1nO0K+elJTmBmo+2!?^gYI6QgEkJi0xvBH`_saH znuG3UpF+f)Z~s3kI-H-^ch|6=u=}g5pIU*tzHWaFb4zdnd#lFTZRc(^2f8iXtv!6V zi@9y#q2#umL*C`=_Hnm;97qP)qzCs^xEboigUle?8!X3vD2YoWN;s^$!r_sGJQhP< z2#4PvlVbTAdpIm7BH=J8gTByYDZz6Z5=273KsRzdNZv$(`wi;E$OYtZvOj0&iZf;( z!Hgh5i&PH+`H1-!Yv*IDmEZMP;Q87|Wfp$@BZr6YVIKu5k*=}xO^+(=y!fcn166@m zzUI*@Y%AXhl;^iSE_3o7#v4#?&`y2@H+ot^&q)X#SWwF$nBt}{CzHrQ0L0KPCnbvL~L| z1dWwzbSGJ(%=j8-voX%(u9>^}?l9lo-5^`GHJM3LrIypUO zy}7^tsjBV<6|$V{?sr?_)TvYFRi~={@Bjb&%w~(%;rBOx_{SH1@iU$7FX=@68I{cE z%gDU0<8`NXJjd%txzijw>QC$0(Qw+pj>gkQc8ob4!;Z11W7#q8bR0XHPMg@#eA0;|{Ko0{w&ofsbW2xa<^ZE`^HdiuvaUhlxLCzNn-d~(Wn zd|-5hAMi~GXb?-Cx3yAVlD zklJ}WQU~uqn#d<2b@EQ6NqiF0WIh>b3ZH^Bl}|;Q#-|}o$JpuUFN4p(Stg%}Gz(=} zD9h%vahAj9AkF1-k>>GvNL{=Oshf8r&FAxx7Vrf~3;9B%MSKy`V!jw@315P=lrKeE zhF;6iYdK$zvkJZfX(eBYbjysPtU6>-pBNsNKbJj2<Y{8!a zf1fv?^mW}VH>>NXa`yAl753w~AJX&257)!(c{+>Kp)>UZ(D8bl#mH@RI-gC={ZPr^ zYMffe>YCIuUy_=Os>?n(DxcL|OjfT)ju+D+c{PWc>1sZ5RPTe+`Lfi~*-d&?KMu?_ zL9Oe{Q*&s=Mb&Ub$}g(fsN=KG|97y0h>q@-ciYAIF<`Gat`ecq?z?6L|X% z;?X|5rZX6wV>6i6l{tC@E4peA#SZXeBjess%;3c6`5C{#u(gJg9hB5lvK`5`ZIr8} zWb0#2|Clp8=JLwoLk7Xahhl~Wk7qm-JL4H0otO?8&yP-dLdLn>7+}h{Zy*#i zePP6h((@CeXdrk1ghJ-2ao!`0j*NTCGD5L~6XQc8!vej5p;#F@g<=KI0IGyyrvTh1V zmwJL`^J$Lj`^TtFCfc|QD3`OA=cZ#eN^)KDHIptmdo3krEj4!y8T?PnqtoQfwUn&2 zjJ#i_SkmI>jK6OA>a#Rm+DXnEi=_J$LX^>qN9Ui;kdu1xPQ@9_9s@=H4<3=l4NT^_&}=9==3j1Rl`37>0x!pAN==Bmn! zp&0Mv=!j2#o8m`M$?F>!AH=uEj|}?CVufuODP)`+@LdSS@t&c9sZn3Zc*!#Z5WqSP z8R&$jix&_-VF$882@-#5^s5#5a`xkz_YjhA=^ogVUh`k~FBH7`@| zEh+6Yoza{(x9?MX#zN8WB;U6eFYQ<^dZ%$MJ!fI?R{YJF2Wh$gbHXDtDm{wVIa21V zpQAtBVDvK}^z&`}eg3Oh_b$B5uj|a9)~*2sY69@+r%RpT$Lo1Rc>R+j&*x(J>B#jc zRRQ-k&PR}e{Vs;lnp(mTT;wF2 zC?v$IxpHB$BGNx^nbohK$1C)v>0i=MbJMyjhEuv}?te8*bD+Re_Dui-4fA9Y`TOZUi_ZgxJ%%$@661K_xN{pxEk zUw?UFYSDAwUioRRJD6K9<<8Z3_zpYw4k$4aux=h#X)PaWG#M_g^7ImC{t%E`RW(;^v`s9vvbYrkf)L(S#w0! zj^&o+9b#_XeQW)iHSxbbib0jX^Y&noUg7q0+;>_^+7flQE4VhB@pffv7EbQOacy?v z9g~srR;qNz#!=qR(nNFH9>bl?ytZA2JG){izsG<&>i{XCrElZ!^I0S^Al>AHu0(3AIT>53{;OdzF~-b)-y5e^NIE6992j<;4_clnjZ08i295|Mk22KF+B(O zb-Da8$9nf4b@`pHt*&xcgZgq2zuQx$m%oip+!6AGxE6 z_fe|fh=jeGgvsziF4Ri)e4sMWJGSe#g_s3?;rRUceN)l860u;Xm|FX>sqQN;F;uTK zG;-^>wlHxaGsW7gEgaM?*IIiO0D7y;n;@BsVd4W5Qhw|?aBt`@hLMSSC4%mvo{?b& z{{TkAoF2evDFdS#Xz*f~V~q3(K5+vWJ*DFf1V(2J@-YHV%Z&cyUcez2;1GKQ6p4gi zZ;SV*?I-BtA~;1vk8-KO)l(M7pjilnPXFNO1d&AGHHcdX#d^jEC%`=rVZxxD(2bl5 z!3<;GVc{?e*1;};-k_Var+sb2<6}MP6W{Q=43Zry zSFrDbXYi71WJrc)BVHF{=UnG!Tm;Bm6XT;Zk2!a{Kwt^$REL95K?$*x>cSamab@sD zHxfpyCcNaVzx8jZTKY2TdTWu;8=5fBUvFMZN+-Ok5abIlN_Kb9ULx5`0`@Y%G<(v# zk613zo-dm6pIpiCoAdykvNs9GjXy0WNDKC;5GzV*Qlv;to&t^V%${=f64p<^Gl6WS z#HhOl$TDjXQoe><4`ZQT5Q|X~5xu{XSS11{px)BX+Hky=&$D)8vH6|>E)5&Eh(>hx8$9Y zcdOs24wmnc%J&4y_x_EJ%V^;i3)iyp-fXzl@cqW6#$ZL0RM8ZuXug-#{Fyc>k+=CDH@01qhWe=LarCWyGD$-?R~tFd7fize!?8fXMOkj|i@bDIatW zt}$S1*QDSXf_x7G3$H)kP4)L)nDF}i2G<@}k3UPn8U1pTDtr!up8m?Lj`}_z`kWMSWsbC+M4*#K>|#A!qbF`{|Nb7(w4&qO9n9ryrBEkc<+j zk?uQ;A9JF=@Fy<+Pt?!fBas=ZSe-NV^|YI5!NdY7v0%}+IJDF$7S;t4>*r!Wv8Oz+ zI)YYWJr;(S3YSusyvudVWnxL=eQVR2)k$Kt{5Sh=^^03N@3sbV4&Tc;CiWf^O_^(! zoS?-mS=@`2%Q5#YwP0hO-cMT2>lbfcT+CcL8c5w5Os$bpYnIQg9tfm%&siQ=QWr|^ zS@MY&>b~B+(6QLISi9t0>R8$(X72phTDxXTUpRQrRumb9U+nnUTDjTmKeje~#Uxg* z>?_?@&aESd=*wlWrZp@z!aE-=S3(8)k={=Iw!GYoVHgMnH3?I1_-PRUTF@kd^3I*q zF`3RaBS9<8FFOjFc)1%HJnHdALMfZnhqzyzEoz#cQWGkHsG1g5)1U<}W}{%ljnj}o z8iap>6|io&eVksjKUqdfhl`0%mIIO~tNjGCs3Ef=g_f4Wh)_slys zX_qLsdm{|1nM_IRsZzxaKlKR`#+WfMsOI$7woLgZwm|YCjLCvo?;wOQylZ%B-0PaY z;2C#$A+w(t_qZ6o2My@rA-VVGO?w6=C&s;1185JW0NVFYjQ7*Ee$@2!^@Qx`Lsmz~ zeFCP94P5CT7#o{-NJukeq-@Aag@8Sv$_4L3x|fhiJ@uzWrl(E{;HLp3dZkMs)snxg zgJybR;IhYc9-14M0FAH!?Fo_k1EZrZ(lMZ8x2x2js1QP4RKk2ce3?K5d6`WHi$%x_ z12ldN(`hg`36&DG9OJyqbRDFtMpDnn;B+gU8%CipvUxFseyEuT&JTD!iey?Cr!I*; z3E7n)RqBXQOu~z}v{5=8%8FW22%3gQtDl)WvGz2h@ojCsrTLvwBO#cTEyBDlrOilt*8 z+jp#0Zkvx4(@G^r*_ylL?aa3_mpWItfV*KncK-6sgtelIw@<%ydO2a03lz1Y%r80e z*Bp64N3rB6Ui5wJsC;BW-@i=IS+l~jBNAhYd9};FAJ6<~W;HKR_ncUJRLtqUZ|#eK z%wb5~U*COm_rkQ4R3bV{L9LY~f0~@OU=Jjhib-W+S;I=rAMXCi?$v2&_hGT&2pG-8 zqudt;y{(r6CVXxkTkF5odESSMpUgGj!X>qpYako?U#pAF}vSh?nVp%?q z$0z@5;2Gw0LolTHjtN5aFvz`#3LAgEaDf)Ujp%Km1%B3kXf7Lm>-q_kf03~x2tX)$ z=5fN(k;q`t;}GBgXIRloW*06ml!gQN!ephQm+=?F!oZ+sXlfMTLe^cbV`qu(A~^Z^ zhe&>tdtG(K$V;-uFQHpirMvPMTD=^*bVaN?DCTtD zw|1=+m(R7`w-)>&JujGECZ(4J(kmbnw&l+o*V3|HpSd~nn^)J8(_inp+4Y;ED1Z;mfQ zM!NG;i}N>X7wYd@3O}_viPZZ+@ta$2ZCT3w*joR{ptBaqGKXl^g7S=9`zyc^RmnFC z^C8BrbTP{lRFQw1kY~~TDK(+?Q{Vg zwL(xwufI2TUlw*>eZDYZ7a_X9LY~DJ`R~GWgd&@mQf*bFIw!AlHumf8T=)aj&`=zL zIE=!CIA@h1I|@yLcBgda3Z!pHb&^*Lix|0zn-ejMxZ=YZ6o z05AJ7sXwWTwTNc7S}StolV(lp*BDZN+cncmrWu&<(J>(s+R9g>O+V z9)=r&&r7!#N{}fA@CIXJSSq26DkQ0e1CwAUyu@30LfJ|o2wkuuz7gV0B3GTtRc6|R zHZcB>6(meIVwUBUu_S~Uxk8FuHX7s@1Hh)`>OD=x|565XBm z9bF((U4^8DS!$5-YN1U@+&SMtIJEm_H&ibrOBrJRj*pXTehJccCn`HL7TWJ6=7*9p z7KYv&y*0YDBamCY%t^W1m-!!G`q8D;VyX6EAot)}ZsFo{w?GPm@yTFMn_MqWfM_!CFqiV#}>l z^X+T7#q%AQ1F(NunUq$xRB%77YPoo&?GL+t(seghYCL>z`{A|R{9x`jDR&z#6t9*9 zayx_Rol<%y^-MEYbY{l-L|qr9nzfO4IE)!ht79E_`Iu<#lrNwU5$u8C~3$j}ml=J=~WM9Eb3COxAmIc$O+< zB@a8-I}+vnDn_(=({_%u%F`NhMPFm`iN{YcaDXc|PMD zq=96$l7~I6BDF!Gx+McQOniccO z)R>P3DZt0hTAx74+%@Y<))}zpbliZF;Tpq+OxGriQC0h- zFhPVs7#SX-N{mlrSj;9%=%1%jCQV`}ia4}TJZWL!`v44-j9kWCNYo^wPjHo_ z>JFI)$M}9=Js+qKWFdDGhP_PoB;z_8dJE&aFuoIh8^xMPPFb1Haou}NMRI>(Pa?6B zvE00uBVkR$&4z`+?=>%Olah+&+SW31-pss}`TgvN_$Q`hq#t68_t(QG{A)MZwgaQtHm-;Xvx1xsFegGUck7?!~sZyWZ+rj+F}Qq>Q?` z&QFTB-r|>s=MGaxX+gUiy5YqF*k!I|179t+E_4O6w@BGr0@>9!%@48*-t4~B{r$u9 z=D$eFMwbtqSqm*UE5r6qFzhTWSu+2#p5^E671qx0T6k{Z=*^~;*10xxpPKpliJK>a zDJ4=$$&xXUvSqIQlcWrJVq`N_EIA?RTIydJT&=mADn55C;5;6*9=~Tju3`@?&wBLH zu(EBXK+N3}v^GoDW`;iYa)0N23CsJ6aeor`YrER=bhmXJvbVVe%G+7qkw*CvmM?9w z81JMTDW6T{cXBw&=b86A40lR%_gf4f#Kxff z14|4_K5!V29}EjrFcr!(;NOUx0{r1d#Q1`2e7qVU3>=Og{9A-(*E0@BFPnve!vRgt z8^S#(^)OEO_e?z)sRwTif!lCj8a&}M^(Aw@bU)GeuFU6|dWZ|pfk?;=zh~;f#Cq5> zYZP3Vhh;+-IAy&UWdl>JuY#fhYM)$Rot6lT+Cz=<^yi}R%9us1&#ulPf`Gv<%Sgq@ zTZyF{hr$U+QKzXiq4yv>U+}!j)A@!owtdZvWnMW8bI?}BgcV|%;~IE7TlXg1aUS~6 zM1+z1cUvyEjv#1$=^0EiR)TeA zUpk-3dZTZoNP9Nw6M5=;CVyom|A-zof5Wq$Hu8+VPB(A3(ZbRKmguwWPK&q8JS>Ibd={pCK~L5 zXLK?YEBlA})6~ZDxvNn&3fY5sc}A5w;c0}6BGux26O+^_g#Y6MzQGIRJ_l87D3%}b z(lMScjt)$X4_;upS;3DfGb4Q1hmHAN>hK_OVcw>W?mENtq{7&_vgD9O5yj9lhHT1- zvY2-Dn!210r697ZYIC-@;S(&V;KfuFVV2SgD;QF{3@?>Bq`VrT@CfSqgvDR#Qm75& z6%qRBj;-N&)2gU7ltoI6nzX!Dq2zFVtc$F4N4rQbC@qUJ_7Rx0>tE6h{Q)JUhZcy( z2w9X@fcXIubFDH4vgg#lK#4!A4G)Ptw8`px1OHM8I?(&iL0YI5JXZUB@pqfnoT))) zzU0hbtXWE3P7OHggVuTpegZbf{DJTGv&-aeu-La$yIdP^HUzB=lC^;@@Bi-Eh2F)^ zdpT9CmO@}3Up^jiLgLaWSsSTV*F9SvyIvwWL6|QMEe{2pO|nFWuD5^p+|%ZO%b$RO zA(>OLlu8u*@#Vf1_m57j3Y9M5g& z6~he&9mA4in0oEJXUko8*8yiu&{`u|Yv?K#PMvhAlv=u!ymWclx6<;X=|C#%t(zrB z^M=m*!h$bqPD`e?uaHtJmf9evD_nW;CnbT@)}W(Rabn0GyEqhO@rPS(W z6ob=uk2qHozTGxehMKxPc#syRYU5F;TW8Zf1rX`-UFE zTuuK1HdeHzPGb~4j#vxcbO|Cq;VA3T4E~*!VwUm9Tc`%|)=lPQix>~fEi`A2xB)6{ z43VMvA2H`md?mER)R#GLCe}vbHNp=88Lis0db-nj&TFC-xT3$p&UuF)+~1&KP~V8F zo2)@%WDQWC)#DSNQs1dj-^uE0&sIx<5w<-LTA>?-%;hQLC2NeA%<9v-NLp<{S}eP7?`9SU1l{P%v3vNYBrOIZu889)6!df^}W9%XAo4thVZg4)mKsc#-x zRc!J`sqbEtL&5lqk(e=j&L&@KZq$n9`62(Jua$SHZ^*S+Us&FxWiYJ16|=FIpn4N> zFuToULxyz{!40r?{3%i$sKhqK3>fbp@cx1iHr&{PRDa|DVd?LJ2X4b(Ipc%f{+a`_ zTEWi>B_VE5pw1(lZOFngjuk^Uw3X>Q^_RC(Gr7l6e@vTO5igEZw%MOkc(`W-L6?z3y!cy=Bh(xGGu0LAKP$YpJLJsV~nequ`ojkJ$A;1 zhwMHCx)^2F(BvV(hvKJ4MtQk?U&ty?44fR;EdMc@MQck>2b%=ljE`0aH+BK`3vSnm z3pj%s$m5#9Ef0(^tCIm=b9m~}w>k@t<13Sxpg>|$zrEvFk89uIp7w*iWDL^A*lvHz zKXk-!@1ew~XCdE5D2`^{Kj8E4jC8NAZe)ruJ4cKF`H`{PQx|!7i2eSmIb?$48Ulz6 z&iwV?z4Oa^-RF-qE7$(7W>|BV(;_o&9f|>1b2H{XWWtNmk7e}=WVJ2ZZ<7E^_!X*T zz?uo?DHqMq_ao3xgo6jy+dnqq^}>*I8#N>?oUD{%{4;6hV8Th#jd-*pn|AX2Aw`11 zM9Vz%M=0>8Mf1=K^G;vE3&cO401yrH&(7Bxt~ZFOB}*yyE!EL{%k)akO0l@tE7to0 z&Z(evO0-VNmff&VM4R~wi|}qKhU#r0R?MvzQ+LgEtZyF|aPAFS_lnlNYP&*)T z_bhoztNp7(cTe1&5ceM!kDm}vJ}>rPlulj}PmGGAGmmt-t6YozZ|SgK|4aFR!gl=; zJq}0pz536ou#e9C+%Y;F(;uh93H>>0cS1iv?dX8Q^ZH3T2zoEIJE8Z{*>U|89nvoA zzjpFT3x#i%-70&t`c}1MDO*ndamJ4_ew_ECyw$Y8wu29>&iT4m55sNL>U#C?T4~k0 z@$bZoJK9zU0;LD;Zj(w6&9#e3g_5;st)T4fhPN7)Mps$_1$$N#rGnPE_Ibz|TwtCM zMPcUpjG|#IzHcvED=2}X`xO}Rtd&*2oB2-Wa?ff@psf9FqEyz2D=)$FeJ#hm&@Sdr zi&tiG(=%Kf-83C=WBc`;@>v(1UEvPV;gJ3e9nR`spjA4nAEqjFK;Z@bgnY&p>x}+I zD$Ek}H{%}2!wic2g&;p6@e|_Yl*C^a2d7cv3fDmu59kk3mk01{&?OyE*oWcR*;zX4 zz_Y<2>v{cr9PYRb8ct{sn;h2?K|zSSvK?_G5V3fop+ zlnM{b#fo+pVl%B}=fBx`t8;N?*&WEPS$e6cLgmuk|k$h=)R?B zEw2C%4pOG<^^OUv&q+n?xUxsG<}0sxuh@55Jab;^8x(tak@tv0qvF^VX=q0D_{G$# zc=e)P#NMrVr()S7l{boIO)JAIr&fI*UA=u(>^&`YoDtj4iY4bhwmuI9c4iI)SM78+ zOa7H@fx@QM7^!f-l+%9K7|7_J>--en71yp`d-dhT%zO3>R|^BF z9YIHjU-rLP}yN&NP9xTAg&-1wMLgUX} zS(L|xV&l&nUC94&E`$NxAEO=8LY5X&y3R3{q=$@y{a2tX6b@E1RF0M{rpy&-y1_;{ zc14-|YgiwvR1>+W!SG0f3WZZa*phqHm}9OK*bFHChiTxwfJ#8Z@LyA)eceZKqG3+1+nf_dXTrOG~&83 zh(ol6IYc&EghS*L)GMw%19}Z^_h)TxZRYe)K87nm*;}X{O^{$oRgI&m^m0J_|kC=nmM^HZdcnM76)j5xk(Lu@#>!w^GczIc=z!PPPwsxgFHrAc3nfgM zVm^=9i7bv`C{d=mVG=y*xgr-?l@ms}_xMXAq$yEtL{PKYkrxlSc+2+<`!K(uYWtAi zg*YxCba`(Tqw&u6pKL^?yz4|uS2;3gD*Q>Hw$HiF^d0VNIpk_Pwy&!f909p5GD`ce zf9n?ge$rfK6^L3D{t5{x|HdXRlgAH&FUo$12CSp-AL(MkAjReAAN5@JjP_4WMm!N^ z`qe?ak{W4&bbz3Uw^7d<$kqQ$eVG|WPNPt`tN|8ROI{Y6{N(zfo}*ZlW;_WcYk85L znfzQr3GxZ+22p524$$TBcn6iQQ0UC2nt9r>1yU246n=;~tXC%~q%cv@M0~$R>Fmm` z0FZ9_euQ|4)qxg1c`ISg2D9gddMCwevYFo&VO!=?cRKll8GL^n+{}kTpBezDre+eYbRmqfvnStCv z*owuA%lUznRzw`9#jPbFL4zWCNfaU0RQ#Ru*-NyR6LsLGd21*#`!59nPt(Yx+e^!t)z%0)YS zQZT_SB_Jy1QXJHF3H3o!y=bb(mDJ4nb}_ScwMl%gPi#FV9=i;p@e23>9HzNeI<)E; z&3|0a=*n~Y5u!4W>o3x^WBN;UxTGJYL(-Ujj>2#TO<0+Xg%TwwS&3BKAQtUj=~&sN zhBZ4W?LQ^9ofZqu+&7)YV*snubiL`-=7rvS78m1g6b0aLSgS+MS?))6oq5-nj~qH% z-D5AYeXs1#?0_}`w$$my4{};+aPm zIX>gKa(f;Dx-)g~tm`9cF=8*HB_C7iPu@wm7=aK78-JtGv?Rn4`zIz>)_gX6nCgO? z>T#oPCXxQh{z-n9g3AfUx=5a}5rZ@QDLlrrVDhSmB%}|;QcVOP#+}H>+0SHS5vi1X zJ&YnFvN7S$smZ65{GXKkTS_7#F_Jf8X|HK{~`hcBT2qK1~;g1a0s)6Z&8TjH34j{NOMXn+YK0Yzx^|;H- z3Sx!WE((X1(sT$3%a+>bDW?M#+C=DM!6k;`Lh*9+BU;rbWj_B2-T1RLWIhbh(bD}H zVY=T%qGGyd2mVUU3(H&}Yy0wZQdTVh_!Y^N9tP7btHpQIZdZ#fN5!KuuRA354U4@O z#0yj6Ws*uv19Ag8(}6*5CWq+JA0|=EG{*!wm$~QY+H?A&bm-NeB1u9oqy=b42NXUp zgY$97DA2A~7U&$6WfVz*CX6qVitELqT`MIk8LKrP?Y_PH?zFV;gxGpgEI4)FbQ-r{ zO`7LlAGtX~Mii2z==Tej_;*L%8F_c&or#rUsj59t-f`D`ue=NHMub4$oVYcyG%V%T z{^&(9ch_e+gYB4pK3<9H1cUV6KvF4rJ%Lhq{qW7hiyd%<3MTK6l6Ne3-cN4ErC@TA zlw7npj2qhCtN$-LUCJ^2FXN~mppuG1oMR zY9Jj=C6JcFNPRj?DW}}Odhg6QQdVI*|A(#3W=}ub*nWfR2kOMHeL}pADdd~kTSUrT z=GwjCNrQcVhG!VZoV@>G^W1Evv5|L)$SBM&g`O%QS79cCiq&%XBWcfcLqF7)N)|u> zL~nWOY%J2WO;+H)<-2bAN34_TPEX$?`wnTo^rc3;zQ>Kh+N^{$!N3S}?>Nap4%BpVAq^EWTA!~ajed8WLSO?;ukgaPm|h(_LL1|q;G#EX45l4zUeH)lWWv7!~%>f>p?EzQMK*sRHs|9yNq0qNhSQr=<6sm zjK)G^`wa+X8eIM?RS1JM9>W$w(d^u!O9tD1-@dn0C+{o?pm}*~{AAkcEk!5DgXU z<|P>`Xj%CuOG)mcXnm+>MM@&C%zwfZyq~Bl!(YhJSH5?3&Pue?*J%%ZV*OOWc{ymk zELtzCTEHmn;e)(FRf$r#6*+h~yC29J#h$yD#pCCG`m)&5FZRD64h)MIywZSAd|?WN z(`9ZS5rl0}Z-Gi|)AtaSNCy-i2I+{y8OQ}dCAP_=<39cKG6mUBhZpoi)b0fc3($@Z zD7>Vfp~F=KNkzLCu=@(E){n zGSTdX78LDH%39D9`pfKIr@vkqDri+;us1>?GOo$G+)-CHK&tbBdP^+FjrWR-@aV>4&>nD`HQ2b{3 zt@6cl%PoP-TFFwolDcY>cJ_-XII-l>b%`~_nP>8f1vFJY3B=4 z_CO&0{MmCGq0Rn9yE*H_fXDtBxD# z(VwInp#us}VN((uJb1%!BR#TKwOfCI3NJuLg~LTKE@*cVi;s46K;e{rp8<#chIRwm zUDS8b*@*rC9nuaOzD_-zlC@BG^DrcnnXbhwDRT>0wCU@1Nc0vH*Xsy5sjwxGLozs$ z?X9^gk-G-<9=5t!IB|1g$t+=?8_R=~43fXqE?-?9TWuD5Pl=}oq~7!5(Lr&L7d@B6 z(P_zZMdV3K?^kud;ja&ObpX4;;aZ3MSm|5D**UBYZv33Aai#+bFUW7*6?}cT@pF1V z6`s{!r9;vzm^#F>-XORumWp?YMToYrQnuRmQP=G*@yH3O?WDNxlvr^3zKQlD*r>TY z1I^_$RF?VcG?xi<_L%L*@=A-YtCG8|=epd++lGC+adKx5*Hvh|)7+ATlMkI-SBde% zq*gk)Tg-Kp8}F91QnjBIa+H>^w4Bm)%o;&l#;lcsTvg z#UtE;<};f$N1oYiyet=lY;k;Ke6v6e{+o1Q!yhmsV=-lVcH z3TBL=X^G01ZKv`QL>>4vxi10RyM_c*eV_OYwo<>3e#7>Vk)fZ^&~A7t zSQeIv5Wqt4sz_!Ln^mpm?vX{xpJDWng_wjfMdkY+ zP$B@w=!E~95_-=CASa#hB@%gyI&Z()4J6ZKd0b|o9q2|OgB7qlDXbg8Pq~IXo>3Q- z&A72EK?cen672OClfals!F^DDUhsICws+t>S$N9#;Jq+1i8i4GRhubK?I8hZc4Kn$ zVZ?q^8q2nqzL80Mr?6biLB9e z@<^3HF;W=2rS$cFv)@RQ_Gilj^AjHLcnP>EVan@cv!C(!+^}wCcQrIRf@zTi9JhmQ z0KoUT{YA7Q;~q?aJp=?Q$AiHJYS< zAY-W_Y!-UmMoqM|*LL;kE4G$`2_u9Yk!PnUspC9DLkQB2T?*w4g8Gh;VXZ8rVxo zr-RnhqV@FC%;rEtW#ulWvM9XWFfVhHd6|2^l-WLK|Ai${QH%BmEEH+{wS(6WzIOQf z;e`(DB^9(*NLIM}-M4N-Ct&9KZpDMZ&MR0vaOr$(^ruFPy^OPp@`k zB4j=(=2c79YS>R&onlf^z*@Y7fVvepq92NWqId#!sLq+9weho9V_L#o#}`(eBUQ0> zOS)$-{1nE7Ti)5S5{GRXBnNCL7h)IVR8@34cmy{X1j*eD6H1CKoSU~8w=lENP2pQ| z=grt@`mT+FCg+O>RYxogF@dT@0&0&%4u;pRG`j1!j% znepC;RK8a%YgsK>&A40hr@Mc;TRic+)YUI`z95zi+_#>GVmUn9m+HcOzkBfzSMMn#sWu#z_=xAPZ)~y&Y#e@A~$qVTFGvCboh2-i5dQl@?Q{le-ET%_r>GnqYSRoVf-kwbsJ9p z$i`9XpmZIjO}-f4#$VK4U8LBcooIo*qV{|xdQHZWZ|Hg3uoY?%BXq8a2EoLo_{ul_ z7;O+O{!UnLy>%lGTF?YwuQ-Oi?BS45kiAT**vp^V)YQambX*6H_4K)V4;((?LXkgx zTa|176$A`)^^$LZt8_;h1bT%#xBAnzSGn3@c!cd$6uA`2w{69R9aXLt#Ri7?0*q0M zx}E-%nkv^JLPn9&ZQFPHvv;B+=HpLD$JI)4ltZ_j9;Si+O2*k?qtKyZv!?EfCFGa)!-^* znAKm(oFU+ophO24;r9@(n4A)ToxPrFy21ja_q@X~>6jYrd_9PU9T=8Y*&fzNJL7JQ zbT0f1Pf^nno?4zK73K%4%y|ZZ z?V}7^YW!UMqikJD9)c;F_aF)tc@`^Pztbzmu!A@vDpF9trpK~YZ)+p>zi|z*|I*aB z-k=pEmHhht08^70MRQaL@93CvJc5;Cqox>aa}1*oCX){{Us3g#VXDlcfsM3KU3%^1 z9aLd-nhC`BG*&VCh2KC7&mfF1ZKC`GJ=;c^j^ry13;X4kqXBq1pAH&yaU6d6eBVA>knshM4&1BcI z7<*{KwQRMo^@gw@{^R&u8LaSmOn>2mlb9LI#wM)ztah7HYYmf2wd-&^N;LRNYZxjkdi7oQjad4bP ztYe?A>tLI^CuANOzYNN6LYR>qo-&9$L>O`qm26K1AGsFM*Y197iGqL+x8okl^w`pn z0+Yue^xUB5v27h7e8{89u3-F`|B3^qRQkW;>`J<2Q)a z@W=E*R__wFqZD*8_MZTxJckP~2_YRoDE>I@Vax&tGI)sXQW3nI5x+&K#=icRg#6G zyJkt-_pe@%wjUOI`|fS;TL(_X`rd%EFKF!(Y2OAXD5H$xWxa$=BhpKj3nY8(T6*d7 zc6KaUHc0lGwe&43xW0QWT|F;c^0NBH%T8SXWulqE?_|L5*L!aEEV6C5&7auQ*NwYk z4>%75tp`Nw0hwBc$t9UjE*uD$ik1>3Q#p=nmc;oA$&$NpMY5DGwcN98LBziKtJr6C z@qp;q@*pJ_8%rRd-!>_waxPxA%WRNLMX%Z)Bxfv?Ew)RU6=FsuLU=SRSFQ|*&b=^L zOmGGh3Z(>!T2i;%5lCnZni@q@2TM}{dUJke4 zWPC5TB?l+(@8eEo^)1|IqZxC1?c81Z99K|4=SrRq_A<$}V_>mEULYpc`=1Hk; zq(2pL@!CjG9goO@pG8_V>BjHijG;jUdCeje$IMc0&YHB4H=iA`D;27!r8=(KdL0V> z2jAED%-8m=-3ud5MAhxEWZ1}m?FP( zD)Kv`=M@S;r~;Pg5vq|2TPfLwqz^(f!hHgv#ZWxF9lTRx1Lwhp)KKY8N@^*oL((qP zQ+5|64V3Jrq>+*)O7>9FOvzqKS}19ygvcTp`x4G&V+q~3;Ez9|FdhmDW~kSn-UAldlIC8qLsHB<)s9pEH!*C2ulS+0;$-BOH zcr7zGm{}!dR$+G2tH#ygyH^95XM)LRq-0V*k~(%ngPMkw-BL|Qu%=6@>AHIW4$py_ zlTeaNHD~ABBa>krZ-CAnZ z(#%SlRNW$0wXRl1nNB3X@VS9YJJ0&QZ! zrOi7~w>TT@K--@f`%${rxZ^A%{{%OoWxBk=~gKHyTC6 ztQSPhkZtQ`?P8-))rNLaE~GIKmc?bl0YpWQ5RKI-^N$VR1z9k{LKkT~kXIy}k}>SE znDB;)h3wsAHtRO0*)W?MyC6=BVH*y!6`Do}%^Q>WJB~lu(ZKF6QxJ?2=uky65Jy42 zGYva17>Wt!Cm{b{x9=J~+9dpa?nFX6HS-)}@R?sOcBVG;$avy!aQPiw85iQNc=3|x zBSsl14WPC_jIzvMnmq{X=@Ag@ zeO*w;ajHO=8c`^)5n(7n?n2#iFvN|7Vp!ao?{>V?@$TVw4hJh+q{@~+<-VY`?bgKwez9$ipKtr_*?_g}6GtUX>c5CHX2#DY z{1RFKIiR5`g%j($0?tE0>mkv4NX9JWa;7p>VogsV;c(D&STr5}G_Q0icSXOFD3~3NWDT(1IY5hk)fiRoTho4oQ#Ik& zV)UTUFK(DGovHwrgo(5fEPc&PKP_)e(f2hoMy$Cgu()ByPg@HB__#qm1Mm2-0qU^d z*1FM&kJ8V^0n{e~)XVY9HSeDI4G2_Wd!h(+5n+q*AlR&y?gmEDO7uES&~AcFkJ4}0 z6P@vLv&>wZSsY-;vIvlV!^p&vTKpuv7aAOf3e+j`z$P`6O=qKh$w{|Qv6*iCuCjN0 z+_X;7kJKx`TaHQ_1-r4s<9QeP7*B%SCNY(lE>3!U*zp2TOqL_|_+7BLU8nh%t3r_= z_vj9p$u1V~Xl#Fi85BrX)ZYQV$0+D}it%OXYG~u%=zAXv_?7UWS>DWF;g`2_mWl?NyS!YU#y*ecPN7ou|9!eY7DXnFOy{ z(hz9%Duh{*rDEy)J&0nUo`=K{{>gVyIoE5#+vb_KJxNLgFpRh3oyHwHsR!dhzD zT2kJkd$H@SDk*8}a_haM+RqHSMCWI8b*?RF&6TVqQ?r%;gxc6G${xxQDI+?D6Snzg zGvth=RZCZ;(%r$*W~sD!)r9ymr4(PL^ayMpWnmsfacDqJ#|xE+&f6uG)QiQtR#HS~ zvzF)ZQwkeeCb zOG}}AWpZ1y5xX`u;^Ypt*Ff4FgLGZk6EYfLK>hE+A~S@G58$K3g^fQgPZ|JG4FIkJ z(FnMmjS+58025xXfFeaF65eTBgZ3pgs|t$Hk^wQ+2LnW)7i0K%)*GP;=%bKYQN}IuC(Pk_ z7?fvfQ1o0-kh7$+6Sty=xQm_}}0ZTaNsZDyQI%!cg*XmfxYdl(W;^;auMlm)qu z!l4LX50x8<-NM^@vmMvlnJ19!CjI9aH!5i0RXPn(LNB7=#OvaZhrf%m+qplxgz)gX z?%ZLA7TNonIbMgc;^V|4qXRRrC7}3-Dhrbb9L!7t5G;>ed+1tJuWo-nDv;YxcwqQc z5AX<{7a7cdAi5Ln(2OSr5IBZ3X#?YOa~I{YmHZei(FT>?tKe8uL^nG|Nd+b4l!S4% zEYYJFFS3qJJ1!qd>_WD6TN}uVG=h&V`6p}90x0mOYjBP1`6UCMd;bcn&k*-tSW+Lz zo2Lh}tE6louweCGsd{gqx;2o!Z?5|jd-mFn`gxm}TP-=Zu9d-3Un<)-Z(BfY_u{q6 zUBSvGsj_Kh{O&-Y@(7AA(x&R=)yQ3dJ=9wDR^-McFi#ZKZLxgm%u=_w>zH^P4jY%G zM1{j}Cqb1{dV$VPBE%jJKzVyze zm13!?McmT5TDMwux9v~6e%d9T?3WI`Aa)Ii73crVF}O}1CN>@pq#g-6j!2FpLy*z`^N`p9o6V_VV}lp@Ds_LY_)cTI8>DKYx6hveOk+x=o20-79$U{8F0L^^R% zJbp>MbVZyY*OC@}7pZU>kox$j?Fy!9Gkl1Eh!OZG`GzenY4jqCBKAF!K1O z4&|{jVe=G+a)tDX=B745HX2-91li1x`N<~a1O$A_ia>K^UQepbn1N}o- z{rG9mQeh`v9F0$d367#w_!yafa#%xr4(g|&9h@gde(~Q3hed>XlEX-w$cVvBL|)N# zbEI~7MNi;QHo1%o2cB`0)KlKLMnV3H`mslji#CLp$DinuYcK|%8n|V@8O|f@rmgT1Vou}gK3WNpRrzE z5;M??E0YEvw~Y z1e`-b>yTs}!uG|EI#Tf1hN0QX$24BaYiZ)%;#zhYf4e0S+1op~mfgnNHL;Z6%^_U} zrX6VVZTx+H93+nNt`ZPs+=#;G;k^rU547Q;0w$jl6-0YuNQ3+~BFrTQR5>5;6gCJx zpKVhKd6W9^R+8XG4KU5Pi#ahdm+;;QI9J~OJr1TNiAbG%64GQo1!*duhBTeeK$^*C zA`W}*vWV+zl(3sL_OgR_Gk>YQ?T-l%cIFD<9DMsk=*b^ zW^D35eE$;u;u)&i?N3)x6)8Sw8#5JK`E$U)(8uJW5RT-*&n`E(WZj5B8_mK|f0mg+ zdzr(&sz#yGR>kYc!=p5l>RM3Rz0R;X4kag#;5uj{B_I`jFmC z9J6X*_Jho-VVdeOtA;kZ%B&jpQ*JMkhXgGB@ht*;Yh1^{>M>uWkQsc_|UJr>R z2*vb{3}5g)Bw=pI)C-`7XyL*`q6Pd3`>#xnPIw_tW%1=7l1wQS+dU!(6T(9hAB5t2 zr^bg7^TF%!KBO-<6t{1b0#JD$CbGI6U|Fy`SxhTDgD!-Oz3{II#hie~Nbp;G;o{^Y zd-1LjpFiQy$dJc%5H=m~!5Ku0HpH`>DT{qb=`#@W(u~N#a7Bjcvd>fsM`85*a zHD=bLpe}7KEoWi)=F5oSj?>blwWN%NqMJ?g)$seS2^Q2#1@-WRkNZg;f{^bz6e#Ex z^LrjyVru(5wS2@A+~%lkgz9c+9R6wU{0{yES4;;#p6p|_loKt8RIe&)*OlR zO^*ze%P&~$mvgms;;w^ow(HQ{F_wGaNEDMwAH|^NgYt@{b8@b+X=PTo>B8VusO4u-^;afs}1rQ7>Oh?x$u#hnwWGYWlN=MerMf^Rn2#B z2f4dt@>x8Z%pZ7Up%(GF0vMI7)U74wi>37|!=KquI@bwlZJvAXFnBu1e~apaMSG;8 zJ%OUVPz2icabQK~;}+7_vWwsBxz+RgJC>$@*toKj9Av~@oq_DGdGnehFX$)+`@K}Q zl(pQtd}aBfSkm$_0%^xk2fsAw9C>T`TjcO!uq&-PAZ|G*x;p1=>lxDSKx${u(J47P zAH|@_BexFT?iSahIGwrq%SYw9#4cc9BAs7pNo*_QZYObV*hMROp9v?}eYvf~cqhk9 z`4SH4I)af1H-?1IA|oSV_!@KYH3sTcJYWb#;zi&1V*okY@L+?cjCsJ2zDFj<4F0qz zP)wUVs*t$M?$0Dmc36vz?IF6mTYB1UD>jcSVy6iNt_3IArfr_o+S(L$M9K1`a0aGOX*OZoowJu2Z`|C{zmGbO8}r`-+n zHaZE-k%rFa=`B^7lF7X{f*cqghp3&WO?CvY-_`@mBUVHtW)GrsM+LA_ zszRp01Rn#Kx-52PgbIl}n4I~Yjh_-T3JqD+kwyW#HhxThipH8^54q8=(x>$AP)Ft+ zEt!k|Uvt+2+|+fZbtPR{vSdrP<@ZarWm__q!8Z7T!Ne~JFGGL?XhK|r48;&bub>14 znpdYKS+>PZyFed<)9x%zyVKI?%&xoLhk0#ingm_TAVqOznND`5-JNM&k(o3}XLi5; z+ zG?xU;Wn%|Lb5qz-5VDj*-($=iax{vL#-O8l8lFXyl;k`&%#$YrZI1@C9}8I?3s@dY zvY3=gAercx@C4R;F3|RPF#Cy+#c89<1)38Nx+QcrDeEdSG_*YiY7~wR|G~5_v zE|$DGvEw1Rcks~2K5)KR8o`0NJDzQJ4QgOJUieFu^D#hMTGghn(v??eSO4cic6HL8 z!zP-1Hqf>`n7t!p*%7epfRM++w>Csb8Wys(1IstwIB;WEY_A3^PuzEJCv;1+?3m5b zWbDM&hOFE!X@~E`4rgqGE?uvlyUcSvo%+j$u6F#mvWn}~KzO1J@vCicex$$JZb1B+ zhNDnRVbZ3jW&I-l--V@Zg&s98;!9L2+N3PhfqEG~spi;0#p00&=e)mJJ~LzlOQM&{ zCx^o&48gepP%o?~@Kpt3O&%I?A6!O%l}v|0kENhtEtIugPwP6;kkk3 zpa-ds(kk?X)xkSSo{%1B$|H#!vlT@&P+Yzc(#1qKN|&yKdA6wm%-)zFXYBJFsV1+c0sbK%N-HU$Lt%i&Cu`WBi22G2lpI32s)}v*8DBCMMhxb zOeJ+s0g(*F94u=(Vj3AbCf9KYbWhK4U~8}L7mowqX2HX*x9 zw7bUhF7dCG2JNkbX+64LiD%u^7!%pKwmsqoMLI_rpn?q9n zo^jTK5p_~@t(??}uAa%QLFZc8WXzpH#$^pS+b7%;#W+0rI42zTjdzI7)<9Jo654CR zHTC1W#F~yk^@@ouP}abzT?4XVMpnpDDq27bm2L3c(}K+{%iwIT#sWM0g!{kHo!?!^ z?MZ~Fl#B_`++By@EJ$QbY+jqNL$qr5## z)(4FBOiv|gifFpn6cr!>JsQr-F%Y2j;}F9%07PKGdcSiGu#_E}fvzxg$1BNYfx9}i zXa8OZW3lxr?J>q4VG5s*pz~0QVdJCS=%dz@k$r_mmV_CnvIB0=(1@GxoaT-#_Z&BB zIyWd<%g10SpgLfzmIgzeSDqM5{R!%p@C^BzLx0Pw1A#Qb(T*B60e9ReNe_%$#^4y- z|0$_(@|SFc$ZLW96IeGdN0-CDDDG8S)^@0HY=gZ%M}@2sfdMdGi2X+@;)ePZ z)8V^OlVs~Irf@9jA23o<41b!-_~NKTk~NV1UZ2!c(l>~t6JdIQbR2vVqst<9OR}2B z4iP{$f-lM=!5KYDC+|iom}qj3j63)~LRof{KqVmxiM;w##+mGbvz}28RCTl6r_yE= zvmk)x+2vycfsXaztTLGSoh%J!m5%lQF-^}1z!t?|N89+(OWQB44R|_(_EnOrwM0FK za9w?>ra*xsTnL}6mFMgi3eOjQw>X?%{6`aCkPchs7FY}p8H->N`CKc!mW7OUFzqqE zVwyQ4%Pl##_FPAx=8-`57BzJMw^*|xP_62$wQAQbmAo`FY1FsmU zQ~Cxw3I-D1PX=B94Rq15F#wSzn?+KiEX^mXFK>TY|40QyDK==60EC?Zh+KbZ7ny;a z3n3&Phot<&KmZ*x(pj`XU?6-SUxn>^1si&7>1 zsvRFmP3R&u!k`M%4H~DP48E{!NwcBsN#%^JK$c{IqB)i@e^1-;9h6lrM$E@wT!ZG` zyY$XNm1_PcdpQiFI8K;gJU(7HZobqQ$X@m-wq3WYyDr)6OzgC8!uM9TU8=USU*ZpzL=FSm}mTurHYNeOddlCm4%E_ z4D3l?ibB*^uH>Ir8yyW%(Fa_Z+?Y7%7U(ZdHerU6%N&Y5V1I6cDKorjT*3?u`%6{q znX!V9Qxu-^I|=KTjhd0+lBXpmx+KvEg>q(4(q9`lbXFmYJ?$AJ%WS@(A!3v>q^V_z z&tH&e0F9bi0@EmD|4}lnLe~??2_F+`VpnE{$+O#XDZwfRR+)GclXfbmG%}#ftR*v( zX#x{;>Oxv1YfsGNB-m;Yy*Vh5>=Uf(N{zA$0$GmnXFfV|00NgI@+a_{!Hk!bMwdHq zoo4=QrQ35I^^v0%nN+b+x>uVf!c+2aoAcPw$_BL4PooDu(d60b;DRnPWq`d*x`aFy1|!M%#>A( z)r{wZGuH(@gG*nUI3TvJ3zn^?tM$v9!Ws5++{;fST%tMA)inwC?|pQMp0ZTPIl}PC z3Km_Use7_3SiAa0mRP&))apP^)s)4lWGSt>@VWD!8#lnSUun~&En;aa>S%enUwQAW zW8URYP7DT}4^1|T&ferUK-W_GQ`hiQ_iOGSFM`dry5-bP&6K59si(a9g8#gK+;yoZ zSl%|#B$ls4J#8Ya0OyvReSGxsvHU>Y@?dTUY@;O-kuM#XteY%}Dd|rcH_1}by&1jv z+%+rLo2$QOGa$YP(;RyUC3zA5@BTIkxUDgH9C>CZSmRJ_=>*e~RO)0@Kb8JSxuB*F zOByjXbsl#xS<8~0L6Q|vq>UZHt|S{sA1`UmS9wLyycUXG}Z7~6BGT}8oM?Y8KJ z({><@kaYzz8M|LY!t@9F1RrF>BuSX^cw@4xfa8*&lJ2bTt2|k@MEfdhz^8>VE8TH8 zA(2(pd1iB@5T>(8K9N1JsJWV*A!_|6vBi`JE_9_cMP8lUgY_El~k_C*W_>PhUY2yzI$ZQcYx@S%*dm~uS9=Sb!uVgJ7UBZCJ) z-zOXEgXlat7bl12UO8w$5q` zGF;(u*SRAXj-NmN-Dl2NK>Kt!$9hA~78nQ#IYGMY2s&2=%R8f>Q4Vuw)`auR#+)I@ zaKg}3{?ZwD{rI+!XO-w#HJK)Q9uB(Ko+FvYhNgr>D8;uHg{$4+@yY(M)CborEZW32+BKJ5ecLdvaimsjKGQ!26 z>IIAI!_Y5We|~+a#3Pn?E}17bPQY!`lYxP~fhYF`8oqF=WdCe2b@>B_Cco_LQ=?Cf zbqDimL$=z0trjX>q$%LOo$dZ8YDe?4d73QONAKs9khG+Wm?`E_v!uZnaQ03Bu0q8#^*?fW#qP2q0w7nFv_On-@7^j{?-^114&SXi^!KTeI>W083JIVOyU7R$N41 zO^F>nRAK5VwJAr$ed{0*@Ra9XD#@R!UXuDwc^40nI^utoIpj3hYuZ?kV~6$WC3n|xKfG5|HjOgFSMl?g_Dey1Bu&vq(-R?b=5pjuVbo7 zsXLF={EC8bVGJM&Ne|j^0-ay@{)d&PcAx{J?06?dqL!e=wJSNbOn5$RDgFu>X@sp3u@irMAT8;IU8D9+dXxZEtUCdsPHw z7P{tAVc@aP7zU=G{-+xkuoiT*7EZ5DS_!VtW+j~dV(M)D-$Lb)r77#+!NxW3x*LJz zwB((q4(EkmsHfi({$Kci2LDsm)B~*cgcIOXeu;T^r0SPg=x$6nYZM60uEaKf)<(E% zc0e7`A;72%yo??0_h)RQyYXJ8<_*b8;lBXK{W&ZnWpIE73fDN$0rwZLA3DzLhvK3O z%|_wqVbn9UX9#+eUcb2;wIcD50Kp#n)-N1A!n8`sWi&O|(9qz|mu|^&g(L_}*K%af z?1lZz-B6%(_y(yiIU{~hCNi!`FOf0-kw@2Vb1bK(e2FdibJuR^A>qyu_#K1PD6Zj$ zK>G9-$t{v{Cu)jkKwY^?Ev$>g3@15KcZu*#OaK$Ui)0X0dO&Gk*i0|j-Q2tf8c7gJ zqW(uLU=jPE-#I|jC27E$;~Mb3h@BL8m_KcJx`XR9h*@Et{toAhK;!`7YX~Byp{Mr@ z9j3lBN&vg@3qMDmNM?`n0oY)KwUj+e`4nrFFCh^MPEm!=Q{bS0WO;>k2qHY2k%;*i zECCHjvm-0wM|c{APxuSU%P0ld85##3#?OxEuvGlkM4A8+RwIw^8Ul$|X3IXs&uZ6j zrWWS>s|HTYrY$Zeh7=d{o3>OT1uFRUqxG*ege;DL#qj}D7YjnRDsl}08o@Hrxh&{x z4cgjfZ23y^#kH@l4LRFIXL}H|1KWx@uewENchK1zwDl#t3OPGPXD8^iL0cDGe&uGK z>YKG$GZr&@(v4F^jdvW?QH?&g@|-?gTz;YbeEau1#+UxI^R>=fi#n1?0T~nCe;N73 zNU*a%0NO#r=IN40!Y#|s^-h;Gzf)2%YeFUPw^m$v!E@d-t_v2`hwSwMd;M&drno#* z)EF#kgq9))E!t2BtJy`E#H1ONcznWP&V>>@)ul$CQJ!F+Gn>2#kq^{mVM=)X6`@ZVr<4gmLO8XKBP7K zXW#DuXJ625ujIbEazmEpvW{El)?e0lSK`Oj+KP3HL0GdM3r}BK>vKvi?lX>zy@-->B{~ZZc?o<>5Bz^uKB- zNBoV961;fB&7rk7>N!;U#^QzznYv$>>X7nlM;+pCrc?aQ436TNES_cB(5ZW~sC+}a z?#*_d;+?up9h$e$yk7g3KBwQzzm-p8daKAt@yfD(1OK+hfRwilJW}2^)7!T*O`9~j zxAV*TJ9KX^V{bcnq}S zyP)i`j zsLg;1Z8FXPBSlyUF8mPxJ8+AVdjnxk6-ppz845+gl#r9emr@?&1hfAE`jbSZIX!eF zhTEei7c=##HZnE(DVT|KZuPp8hA$gVpYgK4aXJdS1sH{Iq4WEs0nDy~LH{(8y8v$5 z@AvdQb@<3}2Pvt?F#Gxhv{|wDB%{}P8zsz_-6Rq&3ihOQAiL~Rke!@5;vMq3Z4wGh zJ4Kc!g@kj!l|A6etN?(Zx|2MIMd>&+x zFk#XxbWdfw$w=dm4$Nxp8F}xQIzy!%v6L)bz;I)zp<8U|4mR}O*fU+aC6vGAjQ*Yc z;uwNe3ok4I*Y;`q4hVgb8samf&%AOn1kW$|b;104WX{Q-Y3rNx2HVz)_GK{8yaonI zb}+QoBNlna_g>mJ@ukV9gGHM|_RRtN=GlCSH6?fI#|LH`Alvy3^GoKCu|PBy1PVR3 zj19AHO~F=Z6#|WcX+nm%)xt^^p=X7^u@%h3Q8{f!UJAa}K5bk6jxCpA2Z?XVTxG!7 zHjy=<3skO{wym78A^CD+R~mj? zY2$j9>aVoxDZa7*FRtd5P}syFZi-NxZE70oN+v!iW#zAb7= zP!3T@9W;=y2I*ET8UmpToGdS*#dM&5FYt(SocQxtn wYRvCyGTzl>vbah5`JSfu-!--WrdjfyCht8>(R-TG4-H$hxy4Zpg=`M~8>;f6j{pDw literal 0 HcmV?d00001 diff --git a/src/__pycache__/game.cpython-312.pyc b/src/__pycache__/game.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..130d269e0b89dd04e62e0e82388be02d516d5729 GIT binary patch literal 15826 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 YxeJA{QhRT)z4wT~; diff --git a/src/__pycache__/utils.cpython-312.pyc b/src/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cb96147b9ab37c89b68d66305d19008ec7ce523 GIT binary patch literal 3105 zcma(TTWlN0ara()NHX=ZM3Z%Riqr(O^{^bHMFU%f9a)y0Rw*nehG{v>nRgUvkw<3l zXp8L8mIDE$lK_<)w_*|(A^OF@4dVUExW!Z!P7;}#&RNI{?hiZC*cunH$L3NN#Y5a&P(?vBkG zaRG&IQG_iNH&AxTZlHM-kK)|}J7jMQV;wB+mfM#3mb^CD)o#h-;iQf&^4jyRu+Q9e zQqAV{H>6Zjmh=oM1gwAYa&rOhB<3tTBo)o%)MRYI6o{hdh#JGT%5)OoW8vF~16V}{ zBD?e!-9UG2T*eEN2oaoTWVVQPy2Bbe?_igxq#eV+Ai?&LBWV_exx%+>wH5K?=~qOm zoGPUi>ZY`$KYk9xOcf5id0rYR936?wou9ifH}_Wb%^w@Z3-;Q9k?(C?o*TKaZz#-} zY)Vm0?qVjBG8v+nJV=Wr!VIChnXas)>k3gds*xye4vZx-X=N;yNnOm3$q*e0h>kIh z#Kx%Mw9#zd?6R$?L>(Oq;&!u-KE=@i(zP)FOK4p{oxQiT@}Up%m4iRKTJP~c>ONj_ zJ@ob5I<`7}bGm%CBCqxCU-J#u+`}8N1(b{>Bvn;X+nhwrZ8_;QApeh(40NgGA}Y2y zWY%%QA|@P$Br?!t3=ZK7!EFw)b#VM~5VlWk;=xTUf-PzOnqEzph^}2p>WMHW-LRz5 zND+yTQaf8lu_YBk&$61$&_^X40MImA|Lwu@{Kv!9?)!oLV70oh?{B_=d%l6n;F|A+ z`@SR9m+sEj_8+OakB~0dG-L8`Voot#>7*J36cA~VBC)Mzp!LNvV*-#>lYKJT@5XpRqFl8|*9^M7kZ<2?^$j z9;o@AuV~e?zbe-IgFAnF`bww1de|AbeX{0z;R(VX|AU_X1`G41)(hg^=?ycI>BAU26PLV z3!r9b&q``iPZkt0rzxaqY0*B~L{A<55=h^e0`MxrmI4>R=uq-n3K-ZqNJa~DU@sny z!9q!WXJ49{8M-QhmF)5`@jJEdB?9m1Q{1bh(gzg6;l{3F*znJ`;=Z~j< z>-*E8+U&VMPu!lo7mWN@a|72q{R8+T*8rYL{tK=Ks&SVxNfq+@c_5k|=RkR@hGwU; z?C9}SV((iMwg)|>B(4H(GwZjWHp@-+Q>;K z1%3)>_7fW9gc^#B0QeMH=UE;gw3}Ig7CDUu)-rm)4L#z!8jQe?RM-ZBsmeW}(*}!# zZfm{KX<-~cQ5kF?n5sih=wx#M)@TQgb;is4E3pQG>F)Fwv%mSFh1O=z*XE-4X5VW5 zrSw{E@m?zbgtFNZ0BiW|(?awNfe%#&sTLuNV;!^$HSa;M@KjJ+Rqx+GfmanSLbw(g zXc4WD27*0g#tClXH&8Q+zOj|S3&`p0b5IG3xQJu{+6f}NpV54T9XpVv!`Aw2>)_-w zRAQF!1}nR0*;A?3+}H07qt(o0)~d{u)5>}k&!ZwFrNJ4z!NfVJ0`7wUM^^Er&kZ|; z?CwT}K=>wp`7yP|455vvz=$ssTHeDf>4(YWwJba+tZJk)nxgASfar=iFLheV{brqRa-EznMh?y&lRlR@PBKt2SY_8x#G^r*wPG*$O? zElt;5Z9lno{o0E5r7H+Ew|umGY-Mrv>dmW_Sap1@f8<7cNm${4tE68y>U;Kn68JDs znXC@1?HRtYtHhNO_4dx6p1N`By&o(ce-H?jl$E3JCMu_wPCN|kEh``ItGxZuXzBHp z@s)!&&Q&kMp>16&-z`s7rfY4(HEy_>h5{J}wMaVvQ_%BSxM(wrtW=@86RP~zIkf3M u+|1Jnauf*Esv0dR&d*yof{V2*Ab}+=fRi8UF)y+66NJ literal 0 HcmV?d00001 diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..36fd3af --- /dev/null +++ b/src/db.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Database functionality for DuckHunt Bot +""" + +import json +import os +import time +import asyncio +import logging + + +class DuckDB: + """Database management for DuckHunt Bot""" + + def __init__(self, db_file="duckhunt.json"): + self.db_file = db_file + self.players = {} + self._save_pending = False + self.logger = logging.getLogger('DuckHuntBot.DB') + + def get_config(self, path, default=None): + """Helper method to get config values (needs to be set by bot)""" + # This will be set by the main bot class + if hasattr(self, '_config_getter'): + return self._config_getter(path, default) + return default + + def set_config_getter(self, config_getter): + """Set the config getter function from the main bot""" + self._config_getter = config_getter + + def load_database(self): + """Load player data from JSON file""" + if os.path.exists(self.db_file): + try: + with open(self.db_file, 'r') as f: + data = json.load(f) + self.players = data.get('players', {}) + self.logger.info(f"Loaded {len(self.players)} players from {self.db_file}") + except (json.JSONDecodeError, IOError) as e: + self.logger.error(f"Error loading database: {e}") + self.players = {} + else: + self.players = {} + self.logger.info(f"Created new database: {self.db_file}") + + def save_database(self): + """Save all player data to JSON file with error handling""" + try: + # Atomic write to prevent corruption + temp_file = f"{self.db_file}.tmp" + data = { + 'players': self.players, + 'last_save': str(time.time()) + } + with open(temp_file, 'w') as f: + json.dump(data, f, indent=2) + + # Atomic rename to replace old file + os.replace(temp_file, self.db_file) + + except IOError as e: + self.logger.error(f"Error saving database: {e}") + except Exception as e: + self.logger.error(f"Unexpected database save error: {e}") + + def _get_starting_accuracy(self): + """Get starting accuracy with optional randomization""" + base_accuracy = self.get_config('new_players.starting_accuracy', 65) or 65 + if self.get_config('new_players.random_stats.enabled', False): + import random + variance = self.get_config('new_players.random_stats.accuracy_variance', 10) or 10 + return max(10, min(95, base_accuracy + random.randint(-variance, variance))) + return base_accuracy + + def _get_starting_reliability(self): + """Get starting reliability with optional randomization""" + base_reliability = self.get_config('new_players.starting_reliability', 70) or 70 + if self.get_config('new_players.random_stats.enabled', False): + import random + variance = self.get_config('new_players.random_stats.reliability_variance', 10) or 10 + return max(10, min(95, base_reliability + random.randint(-variance, variance))) + return base_reliability + + def get_player(self, user): + """Get player data, creating if doesn't exist""" + if '!' not in user: + nick = user.lower() + else: + nick = user.split('!')[0].lower() + + if nick in self.players: + player = self.players[nick] + # Ensure backward compatibility by adding missing fields + self._ensure_player_fields(player) + return player + else: + return self.create_player(nick) + + def create_player(self, nick): + """Create a new player with default stats""" + player = { + 'shots': 6, + 'max_shots': 6, + 'chargers': 2, + 'max_chargers': 2, + 'reload_time': 5.0, + 'ducks_shot': 0, + 'ducks_befriended': 0, + 'accuracy_bonus': 0, + 'xp_bonus': 0, + 'charm_bonus': 0, + 'exp': 0, + 'money': 100, + 'last_hunt': 0, + 'last_reload': 0, + 'level': 1, + 'inventory': {}, + 'ignored_users': [], + # Gun mechanics (eggdrop style) + 'jammed': False, + 'jammed_count': 0, + 'total_ammo_used': 0, + 'shot_at': 0, + 'wild_shots': 0, + 'accuracy': self._get_starting_accuracy(), + 'reliability': self._get_starting_reliability(), + 'gun_confiscated': False, + 'confiscated_count': 0 + } + + self.players[nick] = player + self.logger.info(f"Created new player: {nick}") + return player + + def _ensure_player_fields(self, player): + """Ensure player has all required fields for backward compatibility""" + required_fields = { + 'shots': player.get('ammo', 6), # Map old 'ammo' to 'shots' + 'max_shots': player.get('max_ammo', 6), # Map old 'max_ammo' to 'max_shots' + 'chargers': player.get('chargers', 2), # Map old 'chargers' (magazines) + 'max_chargers': player.get('max_chargers', 2), # Map old 'max_chargers' + 'reload_time': 5.0, + 'ducks_shot': player.get('caught', 0), # Map old 'caught' to 'ducks_shot' + 'ducks_befriended': player.get('befriended', 0), # Use existing befriended count + 'accuracy_bonus': 0, + 'xp_bonus': 0, + 'charm_bonus': 0, + 'exp': player.get('xp', 0), # Map old 'xp' to 'exp' + 'money': player.get('coins', 100), # Map old 'coins' to 'money' + 'last_hunt': 0, + 'last_reload': 0, + 'level': 1, + 'inventory': {}, + 'ignored_users': [], + # Gun mechanics (eggdrop style) + 'jammed': False, + 'jammed_count': player.get('jammed_count', 0), + 'total_ammo_used': player.get('total_ammo_used', 0), + 'shot_at': player.get('shot_at', 0), + 'wild_shots': player.get('wild_shots', 0), + 'accuracy': player.get('accuracy', 65), + 'reliability': player.get('reliability', 70), + 'gun_confiscated': player.get('gun_confiscated', False), + 'confiscated_count': player.get('confiscated_count', 0) + } + + for field, default_value in required_fields.items(): + if field not in player: + player[field] = default_value + + def save_player(self, user): + """Save player data - batch saves for performance""" + if not self._save_pending: + self._save_pending = True + # Schedule delayed save to batch multiple writes + asyncio.create_task(self._delayed_save()) + + async def _delayed_save(self): + """Batch save to reduce disk I/O""" + await asyncio.sleep(0.5) # Small delay to batch saves + try: + self.save_database() + self.logger.debug("Database batch save completed") + except Exception as e: + self.logger.error(f"Database batch save failed: {e}") + finally: + self._save_pending = False \ No newline at end of file diff --git a/src/duckhuntbot.py b/src/duckhuntbot.py new file mode 100644 index 0000000..18194a3 --- /dev/null +++ b/src/duckhuntbot.py @@ -0,0 +1,1195 @@ +#!/usr/bin/env python3 +""" +Main DuckHunt IRC Bot +""" + +import asyncio +import ssl +import json +import random +import logging +import sys +import os +import time +import signal +from typing import Optional + +from .logging_utils import setup_logger +from .utils import parse_message, InputValidator +from .db import DuckDB +from .game import DuckGame +from .sasl import SASLHandler + + +class DuckHuntBot: + """Main IRC Bot for DuckHunt game""" + + def __init__(self, config): + self.config = config + self.logger = setup_logger("DuckHuntBot") + self.reader: Optional[asyncio.StreamReader] = None + self.writer: Optional[asyncio.StreamWriter] = None + self.registered = False + self.channels_joined = set() + self.shutdown_requested = False + + # Initialize subsystems + self.db = DuckDB() + self.db.set_config_getter(self.get_config) + self.game = DuckGame(self, self.db) + + # Initialize SASL handler + self.sasl_handler = SASLHandler(self, config) + + # Admin configuration + self.admins = [admin.lower() for admin in self.config.get('admins', ['colby'])] + self.ignored_nicks = set() + + # Duck spawn timing + self.duck_spawn_times = {} # Per-channel last spawn times + self.channel_records = {} # Per-channel shooting records + + # Dropped items tracking - competitive snatching system + self.dropped_items = {} # Per-channel dropped items: {channel: [{'item': item_name, 'timestamp': time, 'dropper': nick}]} + + # Colors for IRC messages + self.colors = { + 'red': '\x0304', + 'green': '\x0303', + 'yellow': '\x0308', + 'blue': '\x0302', + 'cyan': '\x0311', + 'magenta': '\x0306', + 'white': '\x0300', + 'bold': '\x02', + 'reset': '\x03', + 'underline': '\x1f' + } + + def get_config(self, path, default=None): + """Get configuration value using dot notation""" + keys = path.split('.') + value = self.config + for key in keys: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return default + return value + + async def connect(self): + """Connect to IRC server""" + try: + # Setup SSL context if needed + ssl_context = None + if self.config.get('ssl', False): + ssl_context = ssl.create_default_context() + + # Connect to server + self.reader, self.writer = await asyncio.open_connection( + self.config['server'], + self.config['port'], + ssl=ssl_context + ) + + self.logger.info(f"Connected to {self.config['server']}:{self.config['port']}") + + # Send server password if provided + if self.config.get('password'): + self.send_raw(f"PASS {self.config['password']}") + + # Register with server + await self.register_user() + + except Exception as e: + self.logger.error(f"Connection failed: {e}") + raise + + async def register_user(self): + """Register user with IRC server""" + nick = self.config['nick'] + self.send_raw(f'NICK {nick}') + self.send_raw(f'USER {nick} 0 * :DuckHunt Bot') + + def send_raw(self, msg): + """Send raw IRC message""" + if self.writer and not self.writer.is_closing(): + try: + self.writer.write(f'{msg}\r\n'.encode()) + # No await for drain() - let TCP handle buffering for speed + except Exception as e: + self.logger.error(f"Error sending message: {e}") + + def send_message(self, target, msg): + """Send message to target (channel or user)""" + self.send_raw(f'PRIVMSG {target} :{msg}') + + def is_admin(self, user): + """Check if user is admin by nick only""" + if '!' not in user: + return False + nick = user.split('!')[0].lower() + return nick in self.admins + + def get_random_player_for_friendly_fire(self, shooter_nick): + """Get random player for friendly fire accident""" + other_players = [nick for nick in self.db.players.keys() + if nick.lower() != shooter_nick.lower()] + if other_players: + return random.choice(other_players) + return None + + async def send_user_message(self, nick, channel, message, message_type='default'): + """Send message to user respecting their output mode preferences""" + # Get player to check preferences + player = self.db.get_player(f"{nick}!user@host") + if not player: + # Default to public if no player data + self.send_message(channel, f"{nick} > {message}") + return + + # Check message output configuration + force_public_types = self.get_config('message_output.force_public', {}) or {} + if force_public_types.get(message_type, False): + self.send_message(channel, f"{nick} > {message}") + return + + # Check user preference + output_mode = player.get('settings', {}).get('output_mode', 'PUBLIC') + + if output_mode == 'NOTICE': + self.send_raw(f'NOTICE {nick} :{message}') + elif output_mode == 'PRIVMSG': + self.send_message(nick, message) + else: # PUBLIC or default + self.send_message(channel, f"{nick} > {message}") + + async def auto_rearm_confiscated_guns(self, channel, shooter_nick): + """Auto-rearm confiscated guns when someone shoots a duck""" + if not self.get_config('weapons.auto_rearm_on_duck_shot', True): + return + + # Find players with confiscated guns + rearmed_players = [] + for nick, player in self.db.players.items(): + if player.get('gun_confiscated', False): + player['gun_confiscated'] = False + player['ammo'] = player.get('max_ammo', 6) + player['chargers'] = player.get('max_chargers', 2) + rearmed_players.append(nick) + + if rearmed_players: + self.logger.info(f"Auto-rearmed guns for: {', '.join(rearmed_players)}") + self.send_message(channel, + f"{self.colors['green']}Guns have been returned to all hunters! " + f"({len(rearmed_players)} players rearmed){self.colors['reset']}") + self.db.save_database() + + def setup_signal_handlers(self): + """Setup signal handlers for graceful shutdown""" + def signal_handler(signum, frame): + self.logger.info(f"Received signal {signum}, shutting down...") + self.shutdown_requested = True + # Cancel any pending tasks + for task in asyncio.all_tasks(): + if not task.done(): + task.cancel() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + if hasattr(signal, 'SIGHUP'): + signal.signal(signal.SIGHUP, signal_handler) + + async def handle_message(self, prefix, command, params, trailing): + """Handle incoming IRC messages""" + try: + if command == '001': # Welcome message + self.registered = True + self.logger.info("Successfully registered with IRC server") + + # Join channels + for channel in self.config['channels']: + self.send_raw(f'JOIN {channel}') + + elif command == 'JOIN': + if params and prefix.split('!')[0] == self.config['nick']: + channel = params[0] + self.channels_joined.add(channel) + self.logger.info(f"Joined channel: {channel}") + + elif command == 'PRIVMSG': + if len(params) >= 1: + target = params[0] + message = trailing + + # Handle commands + if message.startswith('!') or target == self.config['nick']: + await self.handle_command(prefix, target, message) + + elif command == 'PING': + self.send_raw(f'PONG :{trailing}') + + # Handle SASL messages + elif command == 'CAP': + await self.sasl_handler.handle_cap_response(params, trailing) + elif command == 'AUTHENTICATE': + await self.sasl_handler.handle_authenticate_response(params) + elif command in ['903', '904', '905', '906', '907']: + await self.sasl_handler.handle_sasl_result(command, params, trailing) + + except Exception as e: + self.logger.error(f"Error handling message: {e}") + + async def handle_command(self, user, channel, message): + """Handle bot commands""" + if not user: + return + + try: + nick = user.split('!')[0] + nick_lower = nick.lower() + + # Input validation + if not InputValidator.validate_nickname(nick): + return + + # Check if user is ignored + if nick_lower in self.ignored_nicks: + return + + # Sanitize message + message = InputValidator.sanitize_message(message) + if not message: + return + + # Determine response target + is_private = channel == self.config['nick'] + response_target = nick if is_private else channel + + # Remove ! prefix for public commands + if message.startswith('!'): + cmd_parts = message[1:].split() + else: + cmd_parts = message.split() + + if not cmd_parts: + return + + cmd = cmd_parts[0].lower() + args = cmd_parts[1:] if len(cmd_parts) > 1 else [] + + # Get player data + player = self.db.get_player(user) + if not player: + return + + # Handle commands + await self.process_command(nick, response_target, cmd, args, player, user) + + except Exception as e: + self.logger.error(f"Error in command handler: {e}") + + async def process_command(self, nick, target, cmd, args, player, user): + """Process individual commands""" + # Game commands + if cmd == 'bang': + await self.handle_bang(nick, target, player) + elif cmd == 'reload': + await self.handle_reload(nick, target, player) + elif cmd == 'bef' or cmd == 'befriend': + await self.handle_befriend(nick, target, player) + elif cmd == 'duckstats': + await self.handle_duckstats(nick, target, player) + elif cmd == 'shop': + await self.handle_shop(nick, target, player) + elif cmd == 'sell': + if args: + await self.handle_sell(nick, target, args[0], player) + else: + await self.send_user_message(nick, target, "Usage: !sell ") + elif cmd == 'use': + if args: + target_nick = args[1] if len(args) > 1 else None + await self.handle_use(nick, target, args[0], player, target_nick) + else: + await self.send_user_message(nick, target, "Usage: !use [target_player]") + elif cmd == 'duckhelp': + await self.handle_duckhelp(nick, target) + elif cmd == 'ignore': + if args: + await self.handle_ignore(nick, target, args[0]) + else: + await self.send_user_message(nick, target, "Usage: !ignore ") + elif cmd == 'delignore': + if args: + await self.handle_delignore(nick, target, args[0]) + else: + await self.send_user_message(nick, target, "Usage: !delignore ") + elif cmd == 'topduck': + await self.handle_topduck(nick, target) + elif cmd == 'snatch': + await self.handle_snatch(nick, target, player) + # Admin commands + elif cmd == 'rearm' and self.is_admin(user): + target_nick = args[0] if args else None + await self.handle_rearm(nick, target, player, target_nick) + elif cmd == 'disarm' and self.is_admin(user): + target_nick = args[0] if args else None + await self.handle_disarm(nick, target, target_nick) + elif cmd == 'ducklaunch' and self.is_admin(user): + await self.handle_ducklaunch(nick, target) + elif cmd == 'reset' and self.is_admin(user): + if len(args) >= 2 and args[1] == 'confirm': + await self.handle_reset_confirm(nick, target, args[0]) + elif args: + await self.handle_reset(nick, target, args[0]) + else: + await self.send_user_message(nick, target, "Usage: !reset [confirm]") + else: + # Unknown command + pass + + async def handle_bang(self, nick, channel, player): + """Handle !bang command - shoot at duck (eggdrop style)""" + # Check if gun is confiscated + if player.get('gun_confiscated', False): + await self.send_user_message(nick, channel, f"{nick} > Your gun has been confiscated! You cannot shoot.") + return + + # Check if gun is jammed + if player.get('jammed', False): + message = f"{nick} > Gun jammed! Use !reload" + await self.send_user_message(nick, channel, message) + return + + # Check if player has ammunition + if player['shots'] <= 0: + message = f"{nick} > *click* You're out of ammo! | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player.get('chargers', 0)}/{player.get('max_chargers', 2)}" + await self.send_user_message(nick, channel, message) + return + + # Check if channel has ducks + if channel not in self.game.ducks or not self.game.ducks[channel]: + # Fire shot anyway (wild shot into air) + player['shots'] -= 1 + player['total_ammo_used'] = player.get('total_ammo_used', 0) + 1 + player['wild_shots'] = player.get('wild_shots', 0) + 1 + + # Check for gun jam after shooting + if self.game.gun_jams(player): + player['jammed'] = True + player['jammed_count'] = player.get('jammed_count', 0) + 1 + message = f"{nick} > *BANG* You shot at nothing! What were you aiming at? *click* Gun jammed! | 0 xp | {self.colors['red']}GUN CONFISCATED{self.colors['reset']}" + else: + message = f"{nick} > *BANG* You shot at nothing! What were you aiming at? | 0 xp | {self.colors['red']}GUN CONFISCATED{self.colors['reset']}" + + # Confiscate gun for wild shooting + player['gun_confiscated'] = True + player['confiscated_count'] = player.get('confiscated_count', 0) + 1 + + self.send_message(channel, message) + self.db.save_database() + return + + # Get first duck in channel + duck = self.game.ducks[channel][0] + player['shots'] -= 1 + player['total_ammo_used'] = player.get('total_ammo_used', 0) + 1 + player['shot_at'] = player.get('shot_at', 0) + 1 + + # Check for gun jam first (before checking hit) + if self.game.gun_jams(player): + player['jammed'] = True + player['jammed_count'] = player.get('jammed_count', 0) + 1 + message = f"{nick} > *BANG* *click* Gun jammed while shooting! | Ammo: {player['shots']}/{player['max_shots']}" + self.send_message(channel, f"{self.colors['red']}{message}{self.colors['reset']}") + else: + # Check if duck was hit (based on accuracy) + hit_chance = min(0.7 + (player.get('accuracy', 0) * 0.001), 0.95) + if random.random() < hit_chance: + await self.handle_duck_hit(nick, channel, player, duck) + else: + await self.handle_duck_miss(nick, channel, player) + + # Save player data + self.db.save_database() + + async def handle_duck_hit(self, nick, channel, player, duck): + """Handle successful duck hit (eggdrop style)""" + # Remove duck from channel + self.game.ducks[channel].remove(duck) + + # Calculate reaction time if available + shot_time = time.time() + reaction_time = shot_time - duck.get('spawn_time', shot_time) + + # Award points and XP based on duck type + points_earned = duck['points'] + xp_earned = duck['xp'] + + # Bonus for quick shots + if reaction_time < 2.0: + quick_bonus = int(points_earned * 0.5) + points_earned += quick_bonus + quick_shot_msg = f" [Quick shot bonus: +{quick_bonus}]" + else: + quick_shot_msg = "" + + # Apply XP bonus + xp_earned = int(xp_earned * (1 + player.get('xp_bonus', 0) * 0.001)) + + # Update player stats + player['ducks_shot'] += 1 + player['exp'] += xp_earned + player['money'] += points_earned + player['last_hunt'] = time.time() + + # Update accuracy (reward hits) + current_accuracy = player.get('accuracy', 65) + player['accuracy'] = min(current_accuracy + 1, 95) + + # Track best time + if 'best_time' not in player or reaction_time < player['best_time']: + player['best_time'] = reaction_time + + # Store reflex data + player['total_reflex_time'] = player.get('total_reflex_time', 0) + reaction_time + player['reflex_shots'] = player.get('reflex_shots', 0) + 1 + + # Level up check + await self.check_level_up(nick, channel, player) + + # Eggdrop style hit message - exact format match + message = f"{nick} > *BANG* you shot down the duck in {reaction_time:.2f} seconds. \\_X< *KWAK* [+{xp_earned} xp] [TOTAL DUCKS: {player['ducks_shot']}]" + self.send_message(channel, f"{self.colors['green']}{message}{self.colors['reset']}") + + # Award items occasionally - drop to ground for snatching + if random.random() < 0.1: # 10% chance + await self.drop_random_item(nick, channel) + + async def handle_duck_miss(self, nick, channel, player): + """Handle duck miss (eggdrop style)""" + # Reduce accuracy (penalize misses) + current_accuracy = player.get('accuracy', 65) + player['accuracy'] = max(current_accuracy - 2, 10) + + # Track misses + player['missed'] = player.get('missed', 0) + 1 + + # Eggdrop style miss message + message = f"{nick} > *BANG* You missed the duck! | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player.get('chargers', 0)}/{player.get('max_chargers', 2)}" + self.send_message(channel, f"{self.colors['red']}{message}{self.colors['reset']}") + + # Chance to scare other ducks with the noise + if channel in self.game.ducks and len(self.game.ducks[channel]) > 1: + for other_duck in self.game.ducks[channel][:]: + if random.random() < 0.2: # 20% chance each duck gets scared + self.game.ducks[channel].remove(other_duck) + self.send_message(channel, f"-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_o> The other ducks fly away, scared by the noise!") + + async def handle_reload(self, nick, channel, player): + """Handle reload command (eggdrop style) - reload ammo and clear jams""" + current_time = time.time() + + # Check if gun is confiscated + if player.get('gun_confiscated', False): + await self.send_user_message(nick, channel, f"{nick} > Your gun has been confiscated! You cannot reload.") + return + + # Check if gun is jammed + if player.get('jammed', False): + # Clear the jam + player['jammed'] = False + player['last_reload'] = current_time + + message = f"{nick} > *click click* You unjammed your gun! | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player.get('chargers', 0)}/{player.get('max_chargers', 2)}" + self.send_message(channel, f"{self.colors['cyan']}{message}{self.colors['reset']}") + self.db.save_database() + return + + # Check if already full + if player['shots'] >= player['max_shots']: + message = f"{nick} > Gun is already fully loaded! | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player.get('chargers', 0)}/{player.get('max_chargers', 2)}" + await self.send_user_message(nick, channel, message) + return + + # Check if have chargers to reload with + if player.get('chargers', 0) <= 0: + message = f"{nick} > No chargers left to reload with! | Ammo: {player['shots']}/{player['max_shots']} | Chargers: 0/{player.get('max_chargers', 2)}" + await self.send_user_message(nick, channel, message) + return + + # Check reload time cooldown + if current_time - player.get('last_reload', 0) < player['reload_time']: + remaining = int(player['reload_time'] - (current_time - player.get('last_reload', 0))) + message = f"{nick} > Reload cooldown: {remaining} seconds remaining | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player.get('chargers', 0)}/{player.get('max_chargers', 2)}" + await self.send_user_message(nick, channel, message) + return + + # Perform reload + old_shots = player['shots'] + player['shots'] = player['max_shots'] + player['chargers'] = max(0, player.get('chargers', 2) - 1) # Use one charger + player['last_reload'] = current_time + shots_added = player['shots'] - old_shots + + message = f"{nick} > *click clack* Reloaded! +{shots_added} shots | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player['chargers']}/{player.get('max_chargers', 2)}" + + self.send_message(channel, f"{self.colors['cyan']}{message}{self.colors['reset']}") + + # Save player data + self.db.save_database() + + async def handle_befriend(self, nick, channel, player): + """Handle !bef command - befriend a duck""" + # Check if channel has ducks + if channel not in self.game.ducks or not self.game.ducks[channel]: + await self.send_user_message(nick, channel, "There are no ducks to befriend!") + return + + # Get first duck + duck = self.game.ducks[channel][0] + + # Befriend success rate (starts at 50%, can be improved with items) + befriend_chance = 0.5 + (player.get('charm_bonus', 0) * 0.001) + + if random.random() < befriend_chance: + # Remove duck from channel + self.game.ducks[channel].remove(duck) + + # Award XP and friendship bonus + xp_earned = duck['xp'] + friendship_bonus = duck['points'] // 2 + + player['exp'] += xp_earned + player['money'] += friendship_bonus + player['ducks_befriended'] += 1 + + # Level up check + await self.check_level_up(nick, channel, player) + + # Random positive effect + effects = [ + ("luck", 10, "You feel lucky!"), + ("charm_bonus", 5, "The duck teaches you about friendship!"), + ("accuracy_bonus", 3, "The duck gives you aiming tips!") + ] + + if random.random() < 0.3: # 30% chance for bonus effect + effect, amount, message = random.choice(effects) + player[effect] = player.get(effect, 0) + amount + bonus_msg = f" {message}" + else: + bonus_msg = "" + + message = (f"{nick} befriended a {duck['type']} duck! " + f"+{friendship_bonus} coins, +{xp_earned} XP.{bonus_msg}") + self.send_message(channel, f"{self.colors['magenta']}{message}{self.colors['reset']}") + + # Award items occasionally + if random.random() < 0.15: # 15% chance (higher than shooting) + await self.award_random_item(nick, channel, player) + else: + # Befriend failed + miss_messages = [ + f"The {duck['type']} duck doesn't trust you yet!", + f"The {duck['type']} duck flies away from you!", + f"You need to be more patient with the {duck['type']} duck!", + f"The {duck['type']} duck looks at you suspiciously!" + ] + + message = f"{nick} {random.choice(miss_messages)}" + self.send_message(channel, f"{self.colors['yellow']}{message}{self.colors['reset']}") + + # Small penalty to charm + player['charm_bonus'] = max(player.get('charm_bonus', 0) - 1, -50) + + # Save player data + self.db.save_database() + + async def handle_shop(self, nick, channel, player): + """Handle shop command""" + shop_items = [ + "=== DUCK HUNT SHOP ===", + "1. Extra Shots (3) - $50", + "2. Faster Reload - $100", + "3. Accuracy Charm - $75", + "4. Lucky Charm - $125", + "5. Friendship Bracelet - $80", + "6. Duck Caller - $200", + "7. Camouflage - $150", + "8. Energy Drink - $60", + "==================", + f"Your money: ${player['money']}", + "Use !use to purchase/use items" + ] + for line in shop_items: + await self.send_user_message(nick, channel, line) + + async def handle_sell(self, nick, channel, item_id, player): + """Handle sell command""" + try: + item_id = int(item_id) + except ValueError: + await self.send_user_message(nick, channel, "Invalid item ID!") + return + + # Check if player has the item + if 'inventory' not in player: + player['inventory'] = {} + + item_key = str(item_id) + if item_key not in player['inventory'] or player['inventory'][item_key] <= 0: + await self.send_user_message(nick, channel, "You don't have that item!") + return + + # Get item info from built-in shop items + shop_items = { + 1: {'name': 'Extra Shots', 'price': 50}, + 2: {'name': 'Faster Reload', 'price': 100}, + 3: {'name': 'Accuracy Charm', 'price': 75}, + 4: {'name': 'Lucky Charm', 'price': 125}, + 5: {'name': 'Friendship Bracelet', 'price': 80}, + 6: {'name': 'Duck Caller', 'price': 200}, + 7: {'name': 'Camouflage', 'price': 150}, + 8: {'name': 'Energy Drink', 'price': 60} + } + item_info = shop_items.get(item_id) + if not item_info: + await self.send_user_message(nick, channel, "Invalid item!") + return + + # Remove item and add money (50% of original price) + player['inventory'][item_key] -= 1 + if player['inventory'][item_key] <= 0: + del player['inventory'][item_key] + + sell_price = item_info['price'] // 2 + player['money'] += sell_price + + message = f"Sold {item_info['name']} for ${sell_price}!" + await self.send_user_message(nick, channel, message) + + # Save player data + self.db.save_database() + + async def handle_use(self, nick, channel, item_id, player, target_nick=None): + """Handle use command""" + try: + item_id = int(item_id) + except ValueError: + await self.send_user_message(nick, channel, "Invalid item ID!") + return + + # Check if it's a shop purchase or inventory use + if 'inventory' not in player: + player['inventory'] = {} + + # Get item info from built-in shop items + shop_items = { + 1: {'name': 'Extra Shots', 'price': 50, 'consumable': True}, + 2: {'name': 'Faster Reload', 'price': 100, 'consumable': True}, + 3: {'name': 'Accuracy Charm', 'price': 75, 'consumable': False}, + 4: {'name': 'Lucky Charm', 'price': 125, 'consumable': False}, + 5: {'name': 'Friendship Bracelet', 'price': 80, 'consumable': False}, + 6: {'name': 'Duck Caller', 'price': 200, 'consumable': True}, + 7: {'name': 'Camouflage', 'price': 150, 'consumable': True}, + 8: {'name': 'Energy Drink', 'price': 60, 'consumable': True} + } + + item_key = str(item_id) + item_info = shop_items.get(item_id) + + if not item_info: + await self.send_user_message(nick, channel, "Invalid item ID!") + return + + # Check if player owns the item + if item_key in player['inventory'] and player['inventory'][item_key] > 0: + # Use owned item + await self.use_item_effect(player, item_id, nick, channel, target_nick) + player['inventory'][item_key] -= 1 + if player['inventory'][item_key] <= 0: + del player['inventory'][item_key] + else: + # Try to buy item + if player['money'] >= item_info['price']: + if item_info.get('consumable', True): + # Buy and immediately use consumable item + player['money'] -= item_info['price'] + await self.use_item_effect(player, item_id, nick, channel, target_nick) + else: + # Buy permanent item and add to inventory + player['money'] -= item_info['price'] + player['inventory'][item_key] = player['inventory'].get(item_key, 0) + 1 + await self.send_user_message(nick, channel, f"Purchased {item_info['name']}!") + else: + await self.send_user_message(nick, channel, + f"Not enough money! Need ${item_info['price']}, you have ${player['money']}") + return + + # Save player data + self.db.save_database() + + async def handle_topduck(self, nick, channel): + """Handle topduck command - show leaderboard""" + # Sort players by ducks shot + sorted_players = sorted( + [(name, data) for name, data in self.db.players.items()], + key=lambda x: x[1]['ducks_shot'], + reverse=True + ) + + if not sorted_players: + await self.send_user_message(nick, channel, "No players found!") + return + + # Show top 5 + await self.send_user_message(nick, channel, "=== TOP DUCK HUNTERS ===") + for i, (name, data) in enumerate(sorted_players[:5], 1): + stats = f"{i}. {name}: {data['ducks_shot']} ducks (Level {data['level']})" + await self.send_user_message(nick, channel, stats) + + async def handle_snatch(self, nick, channel, player): + """Handle snatch command - grab dropped items competitively""" + import time + + # Check if there are any dropped items in this channel + if channel not in self.dropped_items or not self.dropped_items[channel]: + await self.send_user_message(nick, channel, f"{nick} > There are no items to snatch!") + return + + # Get the oldest dropped item (first come, first served) + item = self.dropped_items[channel].pop(0) + + # Check if item has expired (60 seconds timeout) + current_time = time.time() + if current_time - item['timestamp'] > 60: + await self.send_user_message(nick, channel, f"{nick} > The item has disappeared!") + # Clean up any other expired items while we're at it + self.dropped_items[channel] = [ + i for i in self.dropped_items[channel] + if current_time - i['timestamp'] <= 60 + ] + return + + # Initialize player inventory if needed + if 'inventory' not in player: + player['inventory'] = {} + + # Add item to player's inventory + item_key = item['item_id'] + player['inventory'][item_key] = player['inventory'].get(item_key, 0) + 1 + + # Success message - eggdrop style + message = f"{nick} snatched a {item['item_name']}! ⚡" + self.send_message(channel, f"{self.colors['cyan']}{message}{self.colors['reset']}") + + async def handle_rearm(self, nick, channel, player, target_nick=None): + """Handle rearm command - restore confiscated guns""" + if target_nick: + # Rearm another player (admin only) + target_player = self.db.get_player(target_nick.lower()) + if target_player: + target_player['gun_confiscated'] = False + target_player['shots'] = target_player['max_shots'] + target_player['chargers'] = target_player.get('max_chargers', 2) + target_player['jammed'] = False + target_player['last_reload'] = 0 # Reset reload timer + message = f"{nick} returned {target_nick}'s confiscated gun! | Ammo: {target_player['shots']}/{target_player['max_shots']} | Chargers: {target_player['chargers']}/{target_player.get('max_chargers', 2)}" + self.send_message(channel, f"{self.colors['cyan']}{message}{self.colors['reset']}") + else: + await self.send_user_message(nick, channel, "Player not found!") + else: + # Check if gun is confiscated + if not player.get('gun_confiscated', False): + await self.send_user_message(nick, channel, f"{nick} > Your gun is not confiscated!") + return + + # Rearm self (automatic gun return system or admin) + if self.is_admin(nick): + player['gun_confiscated'] = False + player['shots'] = player['max_shots'] + player['chargers'] = player.get('max_chargers', 2) + player['jammed'] = False + player['last_reload'] = 0 + message = f"{nick} > Gun returned by admin! | Ammo: {player['shots']}/{player['max_shots']} | Chargers: {player['chargers']}/{player.get('max_chargers', 2)}" + self.send_message(channel, f"{self.colors['cyan']}{message}{self.colors['reset']}") + else: + await self.send_user_message(nick, channel, f"{nick} > Your gun has been confiscated! Wait for an admin or automatic return.") + + self.db.save_database() + # Save database + self.db.save_database() + + async def handle_disarm(self, nick, channel, target_nick): + """Handle disarm command (admin only)""" + target_player = self.db.get_player(target_nick.lower()) + if target_player: + target_player['shots'] = 0 + message = f"Admin {nick} disarmed {target_nick}!" + self.send_message(channel, f"{self.colors['red']}{message}{self.colors['reset']}") + + # Save database + self.db.save_database() + else: + await self.send_user_message(nick, channel, "Player not found!") + + async def handle_ducklaunch(self, nick, channel): + """Handle !ducklaunch admin command""" + duck = await self.game.spawn_duck_now(channel) + if duck: + self.send_message(channel, + f"{self.colors['green']}Admin {nick} launched a duck!{self.colors['reset']}") + else: + await self.send_user_message(nick, channel, "Failed to spawn duck (channel may be full)!") + + async def handle_duckstats(self, nick, channel, player): + """Handle duckstats command""" + stats_msg = ( + f"{nick}'s duck hunting stats: " + f"Level {player['level']} | " + f"Ducks shot: {player['ducks_shot']} | " + f"Befriended: {player['ducks_befriended']} | " + f"Money: ${player['money']} | " + f"XP: {player['exp']}/{self.get_xp_for_level(player['level'] + 1)}" + ) + await self.send_user_message(nick, channel, stats_msg) + + # Show inventory if player has items + if 'inventory' in player and player['inventory']: + # Get item names + shop_items = { + 1: 'Extra Shots', 2: 'Faster Reload', 3: 'Accuracy Charm', 4: 'Lucky Charm', + 5: 'Friendship Bracelet', 6: 'Duck Caller', 7: 'Camouflage', 8: 'Energy Drink', + 9: 'Armor Vest', 10: 'Gunpowder', 11: 'Sight', 12: 'Silencer', + 13: 'Explosive Ammo', 14: 'Mirror', 15: 'Sunglasses', 16: 'Clothes', + 17: 'Grease', 18: 'Brush', 19: 'Sand', 20: 'Water', + 21: 'Sabotage Kit', 22: 'Life Insurance', 23: 'Decoy' + } + + inventory_items = [] + for item_id, quantity in player['inventory'].items(): + item_name = shop_items.get(int(item_id), f"Item {item_id}") + inventory_items.append(f"{item_name} x{quantity}") + + if inventory_items: + inventory_msg = f"Inventory: {', '.join(inventory_items)}" + await self.send_user_message(nick, channel, inventory_msg) + + async def handle_duckhelp(self, nick, channel): + """Handle duckhelp command""" + help_lines = [ + "=== DUCK HUNT COMMANDS ===", + "!bang - Shoot at ducks", + "!reload - Reload your gun", + "!bef - Befriend a duck", + "!duckstats - View your statistics", + "!shop - View the shop", + "!inventory - View your items", + "!use - Use/buy shop items", + "!sell - Sell inventory items", + "!topduck - View leaderboard", + "!rearm - Quick reload (costs money)", + "!ducklaunch - Spawn duck (admin)", + "!disarm - Disarm player (admin)", + "!reset - Reset player (admin)", + "========================" + ] + for line in help_lines: + await self.send_user_message(nick, channel, line) + + async def handle_ignore(self, nick, channel, target_nick): + """Handle ignore command""" + if 'ignored_users' not in self.db.players[nick.lower()]: + self.db.players[nick.lower()]['ignored_users'] = [] + + ignored_list = self.db.players[nick.lower()]['ignored_users'] + if target_nick.lower() not in ignored_list: + ignored_list.append(target_nick.lower()) + await self.send_user_message(nick, channel, f"Now ignoring {target_nick}") + self.db.save_database() + else: + await self.send_user_message(nick, channel, f"{target_nick} is already ignored") + + async def handle_delignore(self, nick, channel, target_nick): + """Handle delignore command""" + if 'ignored_users' not in self.db.players[nick.lower()]: + await self.send_user_message(nick, channel, f"{target_nick} is not ignored") + return + + ignored_list = self.db.players[nick.lower()]['ignored_users'] + if target_nick.lower() in ignored_list: + ignored_list.remove(target_nick.lower()) + await self.send_user_message(nick, channel, f"No longer ignoring {target_nick}") + self.db.save_database() + else: + await self.send_user_message(nick, channel, f"{target_nick} is not ignored") + + async def handle_reset(self, nick, channel, target_nick): + """Handle !reset admin command (requires confirmation)""" + await self.send_user_message(nick, channel, + f"⚠️ WARNING: This will completely reset {target_nick}'s progress! " + f"Use `!reset {target_nick} confirm` to proceed.") + + async def handle_reset_confirm(self, nick, channel, target_nick): + """Handle !reset confirm admin command""" + if target_nick.lower() in self.db.players: + del self.db.players[target_nick.lower()] + self.send_message(channel, + f"{self.colors['red']}Admin {nick} has reset {target_nick}'s progress!{self.colors['reset']}") + self.db.save_database() + else: + await self.send_user_message(nick, channel, "Player not found!") + + async def check_level_up(self, nick, channel, player): + """Check if player leveled up""" + current_level = player['level'] + new_level = self.calculate_level(player['exp']) + + if new_level > current_level: + player['level'] = new_level + + # Award level-up bonuses + player['max_shots'] = min(player['max_shots'] + 1, 10) # Max 10 shots + player['reload_time'] = max(player['reload_time'] - 0.5, 2.0) # Min 2 seconds + + message = (f"🎉 {nick} leveled up to level {new_level}! " + f"Max shots: {player['max_shots']}, " + f"Reload time: {player['reload_time']}s") + self.send_message(channel, f"{self.colors['yellow']}{message}{self.colors['reset']}") + + def calculate_level(self, exp): + """Calculate level from experience points""" + # Exponential level curve: level = floor(sqrt(exp / 100)) + import math + return int(math.sqrt(exp / 100)) + 1 + + def get_xp_for_level(self, level): + """Get XP required for a specific level""" + return (level - 1) ** 2 * 100 + + async def drop_random_item(self, nick, channel): + """Drop a random item to the ground for competitive snatching""" + import time + + # Simple random item IDs + item_ids = [1, 2, 3, 4, 5, 6, 7, 8] + item_id = random.choice(item_ids) + item_key = str(item_id) + + item_names = { + '1': 'Extra Shots', '2': 'Faster Reload', '3': 'Accuracy Charm', + '4': 'Lucky Charm', '5': 'Friendship Bracelet', '6': 'Duck Caller', + '7': 'Camouflage', '8': 'Energy Drink' + } + + item_name = item_names.get(item_key, f'Item {item_id}') + + # Initialize channel dropped items if needed + if channel not in self.dropped_items: + self.dropped_items[channel] = [] + + # Add item to dropped items + dropped_item = { + 'item_id': item_key, + 'item_name': item_name, + 'timestamp': time.time(), + 'dropper': nick + } + self.dropped_items[channel].append(dropped_item) + + # Announce the drop - eggdrop style + message = f"🎁 A {item_name} has been dropped! Type !snatch to grab it!" + self.send_message(channel, f"{self.colors['magenta']}{message}{self.colors['reset']}") + + async def award_random_item(self, nick, channel, player): + """Award a random item to player""" + if 'inventory' not in player: + player['inventory'] = {} + + # Simple random item IDs + item_ids = [1, 2, 3, 4, 5, 6, 7, 8] + item_id = random.choice(item_ids) + item_key = str(item_id) + + player['inventory'][item_key] = player['inventory'].get(item_key, 0) + 1 + + item_names = { + '1': 'Extra Shots', '2': 'Faster Reload', '3': 'Accuracy Charm', + '4': 'Lucky Charm', '5': 'Friendship Bracelet', '6': 'Duck Caller', + '7': 'Camouflage', '8': 'Energy Drink' + } + + item_name = item_names.get(item_key, f'Item {item_id}') + message = f"🎁 {nick} found a {item_name}!" + self.send_message(channel, f"{self.colors['magenta']}{message}{self.colors['reset']}") + + async def use_item_effect(self, player, item_id, nick, channel, target_nick=None): + """Apply item effects""" + effects = { + 1: "Extra Shots! +3 shots", # Extra shots + 2: "Faster Reload! -1s reload time", # Faster reload + 3: "Accuracy Charm! +5 accuracy", # Accuracy charm + 4: "Lucky Charm! +10 luck", # Lucky charm + 5: "Friendship Bracelet! +5 charm", # Friendship bracelet + 6: "Duck Caller! Next duck spawns faster", # Duck caller + 7: "Camouflage! Ducks can't see you for 60s", # Camouflage + 8: "Energy Drink! +50 energy" # Energy drink + } + + # Apply item effects + if item_id == 1: # Extra shots + player['shots'] = min(player['shots'] + 3, player['max_shots']) + elif item_id == 2: # Faster reload + player['reload_time'] = max(player['reload_time'] - 1, 1) + elif item_id == 3: # Accuracy charm + player['accuracy_bonus'] = player.get('accuracy_bonus', 0) + 5 + elif item_id == 4: # Lucky charm + player['luck'] = player.get('luck', 0) + 10 + elif item_id == 5: # Friendship bracelet + player['charm_bonus'] = player.get('charm_bonus', 0) + 5 + elif item_id == 6: # Duck caller + # Could implement faster duck spawning here + pass + elif item_id == 7: # Camouflage + player['camouflaged_until'] = time.time() + 60 + elif item_id == 8: # Energy drink + player['energy'] = player.get('energy', 100) + 50 + + effect_msg = effects.get(item_id, "Unknown effect") + await self.send_user_message(nick, channel, f"Used item: {effect_msg}") + + async def cleanup_expired_items(self): + """Background task to clean up expired dropped items""" + import time + + while not self.shutdown_requested: + try: + current_time = time.time() + + # Clean up expired items from all channels + for channel in list(self.dropped_items.keys()): + if channel in self.dropped_items: + original_count = len(self.dropped_items[channel]) + + # Remove items older than 60 seconds + self.dropped_items[channel] = [ + item for item in self.dropped_items[channel] + if current_time - item['timestamp'] <= 60 + ] + + # Optional: log cleanup if items were removed + removed_count = original_count - len(self.dropped_items[channel]) + if removed_count > 0: + self.logger.debug(f"Cleaned up {removed_count} expired items from {channel}") + + # Sleep for 30 seconds before next cleanup + await asyncio.sleep(30) + + except Exception as e: + self.logger.error(f"Error in cleanup_expired_items: {e}") + await asyncio.sleep(30) # Continue trying after error + + async def run(self): + """Main bot run loop""" + tasks = [] # Initialize tasks list early + try: + # Setup signal handlers + self.setup_signal_handlers() + + # Load database + self.db.load_database() + + # Connect to IRC + await self.connect() + + # Start background tasks + tasks = [ + asyncio.create_task(self.message_loop()), + asyncio.create_task(self.game.spawn_ducks()), + asyncio.create_task(self.game.duck_timeout_checker()), + asyncio.create_task(self.cleanup_expired_items()), + ] + + # Wait for shutdown or task completion + try: + while not self.shutdown_requested: + # Check if any critical task has failed + for task in tasks: + if task.done() and task.exception(): + self.logger.error(f"Task failed: {task.exception()}") + self.shutdown_requested = True + break + + await asyncio.sleep(0.1) # Short sleep to allow signal handling + + except asyncio.CancelledError: + self.logger.info("Main loop cancelled") + except KeyboardInterrupt: + self.logger.info("Keyboard interrupt received") + self.shutdown_requested = True + + except Exception as e: + self.logger.error(f"Bot error: {e}") + raise + finally: + # Cleanup + self.logger.info("Shutting down bot...") + + # Cancel all tasks + for task in tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except Exception as e: + self.logger.error(f"Error cancelling task: {e}") + + # Save database + try: + self.db.save_database() + self.logger.info("Database saved") + except Exception as e: + self.logger.error(f"Error saving database: {e}") + + # Close connection + if self.writer and not self.writer.is_closing(): + try: + self.send_raw("QUIT :Bot shutting down") + self.writer.close() + await self.writer.wait_closed() + self.logger.info("IRC connection closed") + except Exception as e: + self.logger.error(f"Error closing connection: {e}") + + self.logger.info("Bot shutdown complete") + + async def message_loop(self): + """Handle incoming IRC messages""" + while not self.shutdown_requested and self.reader: + try: + # Use a timeout so we can check shutdown_requested regularly + line = await asyncio.wait_for(self.reader.readline(), timeout=1.0) + if not line: + self.logger.warning("Empty line received, connection may be closed") + break + + line = line.decode().strip() + if line: + prefix, command, params, trailing = parse_message(line) + await self.handle_message(prefix, command, params, trailing) + + except asyncio.TimeoutError: + # Timeout is expected - just continue to check shutdown flag + continue + except asyncio.CancelledError: + self.logger.info("Message loop cancelled") + break + except Exception as e: + self.logger.error(f"Message loop error: {e}") + break + + self.logger.info("Message loop ended") \ No newline at end of file diff --git a/src/game.py b/src/game.py new file mode 100644 index 0000000..cc5f74c --- /dev/null +++ b/src/game.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +""" +Game mechanics for DuckHunt Bot +""" + +import asyncio +import random +import time +import uuid +import logging +from typing import Dict, Any, Optional, List + + +class DuckGame: + """Game mechanics and duck management""" + + def __init__(self, bot, db): + self.bot = bot + self.db = db + self.ducks = {} # Format: {channel: [{'alive': True, 'spawn_time': time, 'id': uuid}, ...]} + self.logger = logging.getLogger('DuckHuntBot.Game') + + # Colors for IRC messages + self.colors = { + 'red': '\x0304', + 'green': '\x0303', + 'yellow': '\x0308', + 'blue': '\x0302', + 'cyan': '\x0311', + 'magenta': '\x0306', + 'white': '\x0300', + 'bold': '\x02', + 'reset': '\x03', + 'underline': '\x1f' + } + + def get_config(self, path, default=None): + """Helper method to get config values""" + return self.bot.get_config(path, default) + + def get_player_level(self, xp): + """Calculate player level from XP""" + if xp < 0: + return 0 + return int((xp ** 0.5) / 2) + 1 + + def get_xp_for_next_level(self, xp): + """Calculate XP needed for next level""" + level = self.get_player_level(xp) + return ((level * 2) ** 2) - xp + + def calculate_penalty_by_level(self, base_penalty, xp): + """Reduce penalties for higher level players""" + level = self.get_player_level(xp) + return max(1, base_penalty - (level - 1)) + + def update_karma(self, player, event): + """Update player karma based on events""" + if not self.get_config('karma.enabled', True): + return + + karma_changes = { + 'hit': self.get_config('karma.hit_bonus', 2), + 'golden_hit': self.get_config('karma.golden_hit_bonus', 5), + 'teamkill': -self.get_config('karma.teamkill_penalty', 10), + 'wild_shot': -self.get_config('karma.wild_shot_penalty', 3), + 'miss': -self.get_config('karma.miss_penalty', 1), + 'befriend_success': self.get_config('karma.befriend_success_bonus', 2), + 'befriend_fail': -self.get_config('karma.befriend_fail_penalty', 1) + } + + if event in karma_changes: + player['karma'] = player.get('karma', 0) + karma_changes[event] + + def is_sleep_time(self): + """Check if ducks should not spawn due to sleep hours""" + sleep_hours = self.get_config('sleep_hours', []) + if not sleep_hours or len(sleep_hours) != 2: + return False + + import datetime + current_hour = datetime.datetime.now().hour + start_hour, end_hour = sleep_hours + + if start_hour <= end_hour: + return start_hour <= current_hour <= end_hour + else: # Crosses midnight + return current_hour >= start_hour or current_hour <= end_hour + + def calculate_gun_reliability(self, player): + """Calculate gun reliability with modifiers""" + base_reliability = player.get('reliability', 70) + # Add weapon modifiers, items, etc. + return min(100, max(0, base_reliability)) + + def gun_jams(self, player): + """Check if gun jams (eggdrop style)""" + # Base jamming probability is inverse of reliability + reliability = player.get('reliability', 70) + jam_chance = max(1, 101 - reliability) # Higher reliability = lower jam chance + + # Additional factors that increase jam chance + if player.get('total_ammo_used', 0) > 100: + jam_chance += 2 # Gun gets more prone to jamming with use + + if player.get('jammed_count', 0) > 5: + jam_chance += 1 # Previously jammed guns are more prone to jamming + + # Roll for jam (1-100, jam if roll <= jam_chance) + return random.randint(1, 100) <= jam_chance + + async def scare_other_ducks(self, channel, shot_duck_id): + """Scare other ducks when one is shot""" + if channel not in self.ducks: + return + + for duck in self.ducks[channel][:]: # Copy list to avoid modification during iteration + if duck['id'] != shot_duck_id and duck['alive']: + # 30% chance to scare away other ducks + if random.random() < 0.3: + duck['alive'] = False + self.ducks[channel].remove(duck) + + async def scare_duck_on_miss(self, channel, target_duck): + """Scare duck when someone misses""" + if target_duck and random.random() < 0.15: # 15% chance + target_duck['alive'] = False + if channel in self.ducks and target_duck in self.ducks[channel]: + self.ducks[channel].remove(target_duck) + + async def find_bushes_items(self, nick, channel, player): + """Find random items in bushes""" + if not self.get_config('items.enabled', True): + return + + if random.random() < 0.1: # 10% chance + items = [ + ("a mirror", "mirror", "You can now deflect shots!"), + ("some sand", "sand", "Throw this to blind opponents!"), + ("a rusty bullet", None, "It's too rusty to use..."), + ("some bread crumbs", "bread", "Feed ducks to make them friendly!"), + ] + + found_item, item_key, message = random.choice(items) + + if item_key and item_key in player: + player[item_key] = player.get(item_key, 0) + 1 + elif item_key in player: + player[item_key] = player.get(item_key, 0) + 1 + + await self.bot.send_user_message(nick, channel, + f"You found {found_item} in the bushes! {message}") + + def get_duck_spawn_message(self): + """Get random duck spawn message (eggdrop style)""" + messages = [ + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O< QUACK", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_o< QUACK!", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O< QUAAACK!", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_ö< Quack?", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O< *QUACK*" + ] + return random.choice(messages) + + async def spawn_duck_now(self, channel, force_golden=False): + """Spawn a duck immediately in the specified channel""" + if channel not in self.ducks: + self.ducks[channel] = [] + + max_ducks = self.get_config('max_ducks_per_channel', 3) + if len([d for d in self.ducks[channel] if d['alive']]) >= max_ducks: + self.logger.debug(f"Max ducks already in {channel}") + return + + # Determine duck type + if force_golden: + duck_type = "golden" + else: + rand = random.random() + if rand < 0.02: + duck_type = "armored" + elif rand < 0.10: + duck_type = "golden" + elif rand < 0.30: + duck_type = "rare" + elif rand < 0.40: + duck_type = "fast" + else: + duck_type = "normal" + + # Get duck configuration + duck_config = self.get_config(f'duck_types.{duck_type}', {}) + if not duck_config.get('enabled', True): + duck_type = "normal" + duck_config = self.get_config('duck_types.normal', {}) + + # Create duck + duck = { + 'id': str(uuid.uuid4())[:8], + 'type': duck_type, + 'alive': True, + 'spawn_time': time.time(), + 'health': duck_config.get('health', 1), + 'max_health': duck_config.get('health', 1) + } + + self.ducks[channel].append(duck) + + # Send spawn message + messages = duck_config.get('messages', [self.get_duck_spawn_message()]) + spawn_message = random.choice(messages) + + self.bot.send_message(channel, spawn_message) + self.logger.info(f"Spawned {duck_type} duck in {channel}") + + # Alert users who have alerts enabled + await self.send_duck_alerts(channel, duck_type) + + return duck + + async def send_duck_alerts(self, channel, duck_type): + """Send alerts to users who have them enabled""" + if not self.get_config('social.duck_alerts_enabled', True): + return + + # Implementation would iterate through players with alerts enabled + # For now, just log + self.logger.debug(f"Duck alerts for {duck_type} duck in {channel}") + + async def spawn_ducks(self): + """Main duck spawning loop""" + while not self.bot.shutdown_requested: + try: + if self.is_sleep_time(): + await asyncio.sleep(300) # Check every 5 minutes during sleep + continue + + for channel in self.bot.channels_joined: + if self.bot.shutdown_requested: + break + + if channel not in self.ducks: + self.ducks[channel] = [] + + # Clean up dead ducks + self.ducks[channel] = [d for d in self.ducks[channel] if d['alive']] + + max_ducks = self.get_config('max_ducks_per_channel', 3) + alive_ducks = len([d for d in self.ducks[channel] if d['alive']]) + + if alive_ducks < max_ducks: + min_spawn_time = self.get_config('duck_spawn_min', 1800) + max_spawn_time = self.get_config('duck_spawn_max', 5400) + + if random.random() < 0.1: # 10% chance each check + await self.spawn_duck_now(channel) + + await asyncio.sleep(random.randint(60, 300)) # Check every 1-5 minutes + + except asyncio.CancelledError: + self.logger.info("Duck spawning loop cancelled") + break + except Exception as e: + self.logger.error(f"Error in duck spawning: {e}") + await asyncio.sleep(60) + + async def duck_timeout_checker(self): + """Check for ducks that should timeout""" + while not self.bot.shutdown_requested: + try: + current_time = time.time() + + for channel in list(self.ducks.keys()): + if self.bot.shutdown_requested: + break + + if channel not in self.ducks: + continue + + for duck in self.ducks[channel][:]: # Copy to avoid modification + if not duck['alive']: + continue + + age = current_time - duck['spawn_time'] + min_timeout = self.get_config('duck_timeout_min', 45) + max_timeout = self.get_config('duck_timeout_max', 75) + + timeout = random.randint(min_timeout, max_timeout) + + if age > timeout: + duck['alive'] = False + self.ducks[channel].remove(duck) + + # Send timeout message (eggdrop style) + timeout_messages = [ + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_o> The duck flew away!", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O> *FLAP FLAP FLAP*", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_o> The duck got tired of waiting and left!", + "-.,¸¸.-·°'`'°·-.,¸¸.-·°'`'°· \\_O> *KWAK* The duck escaped!" + ] + self.bot.send_message(channel, random.choice(timeout_messages)) + self.logger.debug(f"Duck timed out in {channel}") + + await asyncio.sleep(10) # Check every 10 seconds + + except asyncio.CancelledError: + self.logger.info("Duck timeout checker cancelled") + break + except Exception as e: + self.logger.error(f"Error in duck timeout checker: {e}") + await asyncio.sleep(30) + + def get_alive_ducks(self, channel): + """Get list of alive ducks in channel""" + if channel not in self.ducks: + return [] + return [d for d in self.ducks[channel] if d['alive']] + + def get_duck_by_id(self, channel, duck_id): + """Get duck by ID""" + if channel not in self.ducks: + return None + for duck in self.ducks[channel]: + if duck['id'] == duck_id and duck['alive']: + return duck + return None \ No newline at end of file diff --git a/src/logging_utils.py b/src/logging_utils.py new file mode 100644 index 0000000..e27cf89 --- /dev/null +++ b/src/logging_utils.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Logging utilities for DuckHunt Bot +""" + +import logging +import logging.handlers + + +class DetailedColorFormatter(logging.Formatter): + """Console formatter with color support""" + COLORS = { + 'DEBUG': '\033[94m', # Blue + 'INFO': '\033[92m', # Green + 'WARNING': '\033[93m', # Yellow + 'ERROR': '\033[91m', # Red + 'CRITICAL': '\033[95m', # Magenta + 'ENDC': '\033[0m' # End color + } + + def format(self, record): + color = self.COLORS.get(record.levelname, '') + endc = self.COLORS['ENDC'] + msg = super().format(record) + return f"{color}{msg}{endc}" + + +class DetailedFileFormatter(logging.Formatter): + """File formatter with extra context but no colors""" + def format(self, record): + return super().format(record) + + +def setup_logger(name="DuckHuntBot"): + """Setup logger with console and file handlers""" + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + + # Clear any existing handlers + logger.handlers.clear() + + # Console handler with colors + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_formatter = DetailedColorFormatter( + '%(asctime)s [%(levelname)s] %(name)s: %(message)s' + ) + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + + # File handler with rotation for detailed logs + try: + file_handler = logging.handlers.RotatingFileHandler( + 'duckhunt.log', + maxBytes=10*1024*1024, # 10MB per file + backupCount=5 # Keep 5 backup files + ) + file_handler.setLevel(logging.DEBUG) + file_formatter = DetailedFileFormatter( + '%(asctime)s [%(levelname)-8s] %(name)s - %(funcName)s:%(lineno)d: %(message)s' + ) + file_handler.setFormatter(file_formatter) + logger.addHandler(file_handler) + + logger.info("Enhanced logging system initialized with file rotation") + except Exception as e: + logger.error(f"Failed to setup file logging: {e}") + + return logger \ No newline at end of file diff --git a/duckhunt/src/sasl.py b/src/sasl.py similarity index 100% rename from duckhunt/src/sasl.py rename to src/sasl.py diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..7564c0c --- /dev/null +++ b/src/utils.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Utility functions for DuckHunt Bot +""" + +import re +from typing import Optional + + +class InputValidator: + """Input validation utilities""" + + @staticmethod + def validate_nickname(nick: str) -> bool: + """Validate IRC nickname format""" + if not nick or len(nick) > 30: + return False + # RFC 2812 nickname pattern + pattern = r'^[a-zA-Z\[\]\\`_^{|}][a-zA-Z0-9\[\]\\`_^{|}\-]*$' + return bool(re.match(pattern, nick)) + + @staticmethod + def validate_channel(channel: str) -> bool: + """Validate IRC channel format""" + if not channel or len(channel) > 50: + return False + return channel.startswith('#') and ' ' not in channel + + @staticmethod + def validate_numeric_input(value: str, min_val: Optional[int] = None, max_val: Optional[int] = None) -> Optional[int]: + """Safely parse and validate numeric input""" + try: + num = int(value) + if min_val is not None and num < min_val: + return None + if max_val is not None and num > max_val: + return None + return num + except (ValueError, TypeError): + return None + + @staticmethod + def sanitize_message(message: str) -> str: + """Sanitize user input message""" + if not message: + return "" + # Remove control characters and limit length + sanitized = ''.join(char for char in message if ord(char) >= 32 or char in '\t\n') + return sanitized[:500] # Limit message length + + +def parse_message(line): + """Parse IRC message format""" + prefix = '' + trailing = '' + if line.startswith(':'): + prefix, line = line[1:].split(' ', 1) + if ' :' in line: + line, trailing = line.split(' :', 1) + parts = line.split() + command = parts[0] if parts else '' + params = parts[1:] if len(parts) > 1 else [] + return prefix, command, params, trailing \ No newline at end of file