From feb1dc51d2a456c6a6a7ce9a7c8dbd51c6d56b5d Mon Sep 17 00:00:00 2001 From: ComputerTech312 Date: Fri, 12 Sep 2025 18:22:14 +0100 Subject: [PATCH] Uploading all files. --- README.md | 173 +- __pycache__/simple_duckhunt.cpython-312.pyc | Bin 0 -> 99478 bytes config.json | 19 + config.json.example | 9 + duckhunt.json | 205 ++ duckhunt.log | 93 + duckhunt.py | 37 + run_bot.sh | 0 simple_duckhunt.py | 2221 +++++++++++++++++ src/__pycache__/auth.cpython-312.pyc | Bin 0 -> 3306 bytes src/__pycache__/db.cpython-312.pyc | Bin 0 -> 6838 bytes src/__pycache__/duckhuntbot.cpython-312.pyc | Bin 0 -> 136 bytes src/__pycache__/game.cpython-312.pyc | Bin 0 -> 33969 bytes src/__pycache__/items.cpython-312.pyc | Bin 0 -> 2878 bytes src/__pycache__/logging_utils.cpython-312.pyc | Bin 0 -> 1724 bytes src/__pycache__/utils.cpython-312.pyc | Bin 0 -> 705 bytes src/auth.py | 60 + src/db.py | 97 + src/duckhuntbot.py | 0 src/game.py | 566 +++++ src/items.py | 124 + src/logging_utils.py | 28 + src/utils.py | 11 + test_bot.py | 53 + test_connection.py | 0 25 files changed, 3694 insertions(+), 2 deletions(-) create mode 100644 __pycache__/simple_duckhunt.cpython-312.pyc create mode 100644 config.json create mode 100644 config.json.example create mode 100644 duckhunt.json create mode 100644 duckhunt.log create mode 100644 duckhunt.py create mode 100644 run_bot.sh create mode 100644 simple_duckhunt.py create mode 100644 src/__pycache__/auth.cpython-312.pyc 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__/items.cpython-312.pyc create mode 100644 src/__pycache__/logging_utils.cpython-312.pyc create mode 100644 src/__pycache__/utils.cpython-312.pyc create mode 100644 src/auth.py create mode 100644 src/db.py create mode 100644 src/duckhuntbot.py create mode 100644 src/game.py create mode 100644 src/items.py create mode 100644 src/logging_utils.py create mode 100644 src/utils.py create mode 100644 test_bot.py create mode 100644 test_connection.py diff --git a/README.md b/README.md index 6228c5b..f17e78d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,172 @@ -# duckhunt +# 🦆 DuckHunt IRC Bot -DuckHunt IRC game bot. \ No newline at end of file +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/__pycache__/simple_duckhunt.cpython-312.pyc b/__pycache__/simple_duckhunt.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..88c77ff712147402a2f37c5c667115898c9a7d10 GIT binary patch literal 99478 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ξW$Djr;C-0zGE}N6!Y%js!+8Esb6kN3SkpDAmoHk7+ww ziw3??bt_pFZJM8{RH^M6?BiN8uNBQ}ec3*PZ#b~7VSZzvXv+uYrsWcqxkAQ>tu?K6 z>J^*(NCiMB{nM*ZAVAvKE0qG6e>fH&_37jWfb(V%J1OjR9pA{wX++;gihOX@SWj`!1s2c%3`43kWSpkA6{H zN^619u$3`he_-czgdPAtX;l@1cn>Tp;s2*W6=+ml2w!7+K#$ZC$|+y6|2Wkk6(!iw^_oPaaUM3VaNVWgX1dJtzE(h3JL4Rq00^H z*mfBmWhkLNs(`fYt#gh+F-}V#VGA90Xv4O6-oYvmPC9T{a_F&;JuWD^NcA9ibu=uj zcC6Gq3N-0fDR!vL$ig(nPvyciS79y0l=^DZjV6ZLO5d!$Q@v;^U)Ch07JrnpW{w5; zwZWX~WtApl$GrNZ!u4-%yR*%A^!1&yZK0C#x3=Eh>OU|q03Xe^eYNZ7dc*?y%gCXP z_Dks;aFeg;JB@z#JKndw!K$5s%3TZR?zjDM*N?jH?+#c`eUN?{3%G07&9vR@qK%`S zH##Mx6Ij1_o_}xT-I4iofugn#%Q-@tyKO)XoR1<}32@uu?A z*f!;xf)IH1*EO*y4JF4xYEc#w2vFmWAhmd?2J0Bm`F=;IEG=S(JT$0Lkp+`qzk2dd z=_8XrGLc?jK@rkgGRHg!nwrpApOH?2P=Fth^KTu#586Uxr=cw?XDBwSm@tx{{}9%3Huyt5M@wEmsDwb91)Ea;DM>q+Sd@Fgb)Tj1aKS;0sxfa1c zr*9vV@i#xU z8@2D-jO5;^Z{Csgev_8mJD9mUxus6~gQRsWmD(Rv>fv6oN=`fj;~NNHMi!6MQ;D>% z5$;@C<&LNJQ6>*jc1)BwGSy{v{KHccQNt1EN-BM}S%;));ykf(Ju>xJZI!h8(7A0! z6&V-HwiU@rxUh~A3x9-ftF&-Xsj|n^KD1n{E zDhsW(2y1XkM#oN2U~@f*PVu@IijAm}93rWZg-#~D(W)OhKZZ@3N`hDzSJ6St4g{}= zmV~H)E6Am2Z0sV%c;>3dXIBnIen-H9r zY3)#nPX{Zo#|)y8>#@#Sxwo<}Oh<7jA$F8WiCn^cG>u71PKK!5>ArKk>wXutzXVFY?7Y5wWE2cTY zCY$CzfaA6b=VKB~@W|vPt?*}Di-a~9Kug+SuU6LaLd_Nz>SCDan}s^8Qwljz@nSdy zm0b0Uo>jx93AIh**|HCtChVM~nOCS&h2!dgV*=_kY2UAniR^>4&6gjUBK5wb+h&kZ z2qRiLl3B5ldID@BFNw@QLcxmWmK3(Mf0%7j45wq&XUKUTrwt`B8T$^E@6u*RhJ#jV za7zeUrAjK>W@smdQYY+~6da!V6{@!9NP*>3mFq>+l$K|TXiX-+bSb}5%&!dQSI?wA z%+9ByVN2O%Vs=?DyPWpd)H2QP>~zoqjT^U=UM{AW`*k0rW4j+der!~k*M0;Q@2*?B z0@j9L*2aK&BQTzX2 z@pf3`lUBMT;$yUyan)-$H3ocWprf`>7HfIcB>9a{V99-;Rz;7W#8ZqBU+2|+Ct2a^ zehEi)Uc$`#bzb4}TKfN$c4B970-2K>b z?Z);iiC6uQ;{F#HVGBv9^pRD2d`xiAr$PDzLI~BFV80ShRW@Vy6Gc*%iT|Erl1vHZ z9rjnt1&}6B@?nGb>V;vPS7ZfwvR7anIdm`bauLtVNLUCYtngOh+(|KG!;FC#qv%;} zY=eI9;=32$8-I8Fp5uPs;+CGT4lL!?i@EiSxf=r~&&-&xQ}b2R4bze_Uo_@pRbYcZ ze?DV=)BJ{oq>VvsBD14Q6gwGH(#g8mjUnD@DI&^Zf=m9)8HULVJI*Vm9p;viQ{pgsOg@9cPc$NXinYImS=Phid7 zMPqXaQBrTD(j5tGbL<9jT|=N~V<5M2(YPtm$Bk`)-1bFd2Y?<^V8@LeY}s!6qG-Ipwjt|T`X+5r7W-nW6T2^VFBcNXV45lDHsK<~c+5!i1 zx%x*N9ps6GrZ@l_ zm&w2?^hywB?1oV`N%yM9s$^tME-ss)8_tN=+_#6mq*9}RFNN@?;MR<%oy?IlPjsBe}|8Kw*%-?!ORHG6R8k8Ww; z7=1m`)qJ3P6_pjVm=0khO=7z3gNc6vF3eGR8dw^c2xAS*%|uR$RHsZ(5;r+eHITqb z?9%A=6gEdsAJMG8?6gZoWBNZ<<2YdRvA+H7-E=2RUpvR@xuB|Pb!pY~iY=Tq^T{0BXvduHyvOp+8kyL5-j*w0UwJdBRMx!69FjO&Y zWFF*$1D+Qq$1Xl8q^4tE3HlI9H~Dzgs=fHDKfj1FTsjyMg2^p-l5A$JyS?Yu9^ZDq z01;ru7+U536HDHms}X9-)Klw@E}DBnnYpp_GhhZX$y~t#1PZszU!5NgtZ7~}wgC4O z6wjsjihU=22mR;g^XF3nxjV4?DQnGAR*8tMVx3E+Tg1{W!P2e4tZhqKd$DOOnAI|4 zq&$K00=boo#;QL_%f(5jxxTr9+3uP4P~Li<)~B1jh6O$ulO6Jx`SSt=4U6WDA7?b^r~`o>VkCbwOWW-+9_cx7{7~;cPH03P;LzCwk@l^G4bqkssdZEkr!?=k z;aRo2n>A0?_`FO_q{S`PM6iuSD2={cpV6=JH~Py0x%D3y8>9xssyRq$&l>c5$l!mU zK2)F`6mjT>bY`R%_cTE5r+rP0B?2-H%uHgYkxxblZrXr}I|pFmjt!W&c>pHv9)OA4 z2Vmkx4VZN2Ko*^MhdZC;0JS6B1zt?nwd4stR?eXxCkxbzq+rdeoIZ83={QC;@w-y90CQSd$Q_1jifA+ zv^H$rP*E`x(O_z3u3`X)n>K1V##~M}HvQRzKL;puOPjaYQ78k>;^Gu^IOV)UQs3Yh zE4Gl+wy&_xJ~}%7h-O3BIBLJzPu{Q*JN&TWN^r5|kg%CPC_zB)1K4G5ADDX7j8tqF zskCR=Txb`m$|a z!WSq~3Z&2xl8bR|KoZ<&BnT6>sE(rUX30jGV06O<9!snju)lcBfoqen|Ivde$#Qyf zp2w+mwlV(C8tJ#^)xF^i_qf|Wf_=s){(@{lWR zkusoOBM}#eizzyG(1lI38D>h_oGJW1vJ4w}hZ{}7_8g-N3(B;s1{e55w&2)OvEfqM znIn81@r5@i5ypw_=z)BXo%{MLwEqX`?dMT(?A%Tp!8RQn3YNF&U@=SC-;LAFAltaK zfbHiAvnv=N)5|qT8FkPuPAB$JW9ccnK0(Sw;SmgFLFz9kfb@gVJ4=V>X#HvB!j$j{ zzI*akNamyqloEWA5ZVEts+i@9JlfxxV7Y=J_SsFhci!45ns9y~k6b<-&SnM7Wy^-m zIQ(l)pS3P);d*G!p0zLQ;X&RU%HKc*u8>uo4%Z=pTq)N@7%UIq_?k(ibS#BctR#htJ%Ua$>?(I@OJIK9LDt8yT zcS~vakbAG1T6GCWY{}OGAF71&wdBM3T2^2?^Wi8h>&FAkcMv}6$xi0O8CvE$#C$kE zOC#~|VdgucR^_h=<>rNQa>>NuBsdVVZhdG?0lD$NXdO;0ed46}Gp6M+wQ3z!tzI?X zFwgek#4O}N>F6vSQ@0cZj0H~;KO%&T_?5zuh?+-{E7QX__tFY+EN)d+hbzFqj@QtF zDqqdH#<^kKWPr`sSW8~9$ZEq)lG0p}IT@NOD>#`yq4-J z5yyk2*69$g{Al0yFX8XgYuh6F7&QcxgojZS7EIPvC)nk#^apJZjZdnuqDQ5WuA3;t zs-Lvdcd-I1CkC$pLaS~JI^=1bp_)|-{{yRbPZ31pF_)407*U>SRblW(w_YKCzt@OE zG$>mrMC^#j8BK+?oXFIR%WAX!v(<*@s1>|)nzj6?{b2Q)_+q)uC2*I@ z?lQPHNbcBe@oYt`6(&YPv((%j6~sirN6Z{j)S#5;NhE04;Fc5nQitc~dzr6+4y zPg-X6!d9Hl;5$LoxrJG(`)!c38+oCOdAd-_^!}K-E_fN;b$oK2+BQ* za(ku=(2Ku`^HYy%(i+JV)=@NS|ZG*QoZvCI+6H0l;CnUsKiag=;mSDCFd&^*6M9t#K9`c-d!#GX*O`5$#u@!f z1fvs5E3uC;N*O;hj6O5%g@o{3z7oE@-DidW1S9W?GxDydj=VcM^3R-8 zlio@|=4EfybImDRf;z6mNyGykovuz0_sO|r;;(qC!Pj3cVE0Y&R|rm~tDjl-DU9mV zaDZ-_cC|izc2=X^9yU8)@|Lqcr~$3DVV*ukhlO09Edd*OFVo1Vg;x>(`7|>BbW*bg z8sf5lkgQvqV7#Z}!0$AJ-x%u055>-$XJUbC3WQJB0modA(9pWrT%wX6u77wL*FV&g zG{U#jJZ2Eq@XT_el4b+LHaRgVI^y7yh+K~upqk$3-6$CN&v`cjuKM55N*uNOfRc{s zIvQhi9gW_`xD>NS!8T0S(J1LU@Nhz-5)@UtuG702vmv6}es29wd|r=e;*C|hQ6v9ld8RBr_e_b; z8^vl1`!W7c*P%U$i8TBx7^R!jTTqY0^)K{pK`VX*xicLh+@$GymIg3#na6YXpq#ss zGh3S=e3rOgzxo+#l&s7mN&hP8EuTx{`D%iaW)Yr9Jg1vtX4bQ`K22;c zo61o-YAIowxbrIB_!C!mYkKQb`?=7&6=VLD1bvW50~|B{W8ME-^t-R2 zEnj^u{qC=QX8o>_|8)kDYtd7Oq9dxfnB1jK%;~a~x7#cS(lH!X(nW2`L4nUF`E-_k zE>eS|(R7OId1?@MP3De*RfT%2k!^a`I@I(w)SosDt@^R@w?Qi|uS>^oL);pCEm8wV zWR(sp->ZL$#(8>s3{Batq~*7;tFvmo+a>)q>XYG_BzsTb(cBko2PV zrg*n`cYIr3$BtY7r(vVlS7Z6OZT-4ZU)I~tT#;loK9RV_zd&!l5vc+7_CI6e{g2n% zFC?z}Kd86ge6HU1McUeokvGMuOJqgzGmOXSIQhsB^z`xI@3I+O`^>t~N@C(tbk8W| zH&`iE&n%??G2-nvh+K~ncMcf&Z?OEUpP4^ly0h{7>gkv+?#%gSWGqP^`Sh_o0LYeB z)IU39Ut@Trd^H{c{vhnpAHO}o9Xp;jDuln|w(47vR?*&&r?;x^x$ra9(w?Z6Pp43E zYxtX=v4+ow(*}OA8uD*_2H1mUbM@~zsI31pln{Sc(6i|H+yBV(^S3B}$hRecgnz14 z>Ce2bje|5fUS(Z-C)Tws!gq0&f0x&S-BH&;zZ0#D*Yn?2u*vl9=UlT+i&?YYhhfg-<8#j?|S#}{~W6>?_kH0jE!*F)o}c>`1wfenDd7> zKbLSfm85ljm;V>Tk$uV07I=3lB|@T&95R#lDvgnx zZ?q)E<;q2P;+%hbWzKG=k}iI3!xxqBQTyAnlYbBIf}6gxpkTD=z23dNir<7oTtw^R z*CGB$eoxi}{A;9pntm;&2i>Dejl9i#g#v$Vn004?!`b>^3o~1%+mLI#HO00BYl>~d zRweJK&F_e6mAvs|#OR%vwx>Noyx5h=4sVC7M=9gKo1pbFoL}#N3`mssDLush&Km}% z_1Sm5g|A>(E?AS0E_5w}sD%k)Bk;6H1Jzt>6r$_KF{P81r$u1?9&{pqrz*=dLfG!}N}{)*cs%_#ZuQ%lRDI>4duc!Skj-+%xfeioeSG zNN(@Sy|D>-&p%W~R`_GU(S>0&o>qFmOQnmk`YDDr9>`eZJW}sn)p&S68{Lhc z$J3}Sb}8y!Jv5+Yj~!!ehmD4A`jGcfTxu1JQY(xCMi-j=wO-xzLxUP<^R3vq*gZ|D zyN4&AYzx0AQ+9d(0QAS*iQ9LbH0&R&PaQxByv5X~_kS^Y6D{Jg=>Hqa^iR*~v z0?yuif(M9Cvt#;c`mpyf`sxg@VIum)cA|`>C8#CaH86eT`eDAA<)Yz3W4Z9<$i5H9 zEbxVyE?L9>hcP33AIZM|2)z2ArjI7-H;F3p65YFh{Rn#eKVLumOxXXf_b6iiPV8FB z&0m$NAd^vq|AG?#|16={W9aI`FQ`aXQTS5@w@&vY>QxEb#NhtOEkfwEKVhpoy&Ct+e|;lOq3q9;1pbWG;=jc&{f(9T*^T@o!cne_ z1e%WjTdBA3GGfShI<|&<#drQ(iIYIbTfu0rUeWC`UXynAy%d{|7u|Et&a?dYe{k*NpBeeZYX3hXwI6?G?a!g_ z|5xkhB<=q6>OPaT`_SzthJ+@(Ri3ZdSOk6C%$nEeZ)RFHgEwa*Kz(^a(jyL8bTKf#=ULqbUQtS zKnF_c5H`I{ZN-aQ?C`tt-ZjPQ!$rhqT)H^m=$Afv%&{2>oqz?8dEeo#w)SqW?O1DP zuk@fo#uK@FlB8FwIeL8@hdFq<5t5#<=Gfz2o}J-qzi9%S98%Er4Y{{WO+osG*!uzqt_?c)wVTsMQ$y3%;n&$ z7aSuKtj0=jNbgn94F^iXT94j(9&aWJHq59F=~~tO)RK0*E+KHZc!0Zr3vtdn@D^6g z4Fp!MWpawMvs>3G4!hBv?$p8|{Rysu9)I9UHrr~2I%Mmqj^u?4eWj}ftWpi}4jHaP zXLrPo;R9ats1@kiR1b$n#-^fYbEd4Emutp_0Nc6xI-wNN9;YItRlZUI9a~0W>N;Tq zY@wcv2DC-k4cBBITt9jFHwH$~t+2Tmx5rZ1+J&pWDmmHqR7MMbW_^E)ir9`KaOc2; z{mPj1M4-++g?He}sSe+v2z$u&MRGO6^+-#ue@4a*`tUEw^#U2|$=gK67Bc9WNMSP> z)i55>ohs~g%txd`mmbTcD-edhc87l7+av5mG+CwjS9_%QN-GgG36Yta-*WiaAy^(m zCAli4*GIbW&69pQa;F4Gw=SGjk`Q>vNt(O&RkCZBJgKMIEez~&BkNgD7WEhNaQyfc z=_!&)S|j_(+%I!6^N550v5sK!v9`8x(39e_a(#4D1g`Qo$`iN(*E7nqGR21R?ZUO+ zBiv&RqUhi~?a^{qCmyTM3baGYQy8aYn&MOjo<)&xf|7|qG(xaacfJVMqraoqM~xIV z1H)rF-E*MzaO?i|K9rTr;l(C4VNmSViL;Z$Si}NNk^(G6tP1jLPc#XrP%C$uLI9Lo zsW>|oastMq|BoI!-9}-wCWbPbTU!qR&rq4EsP!;iPsPfeBp^YF>}ga_#_4^>y1CZF z-5m#d=}M}$vz{%{w%grw!PN!a5@qF1T!8Ni?oj5YFer8>un#V^bMH}V6G!PGeJzOd;%>@k|yr*J%}g8lq?r@I`FGvF%RaPoz50q@*g z7q0Q;n>)eCw0_5FyAR;L&iW@pcXEUYEy2aE#iWW1h9rLhFYQP={ z9UGVO#)Sk*fmTnFRYF6P8B%{U6oI#VBt+p!t&9n%M1BP7lZ38tRSI=6Lz7Y5ZsICDIrenCW&_iP_=e7w|2tj6p)TKI0t}@kHCg28orkrE|CPe?N=_~ zwiUTK4DuM&k#V|x2*bMqvwI9y5I(mHB!?&yETR@ffIv$y3b z2T6pF+sQ`MOUTKq^vrHxI8IV+)U1Dg^;%Ry#b z49tUzrok(W+DAO5eT@2ZZB&Scs{ndz7<{a8**pdc;wms{!Aao09<+>Vf|>AGQ_Xpb z5Zo<6HQqIp-owSk)^--N7W3MTE=H~K^prz5KW@iWmDy4fxe=JfctXm$;+|5{AQATd z6bI(mtsm0O&C(7bdM~qEBCNgm{I=?Cby$5h=32|g#+@Vf(eu20x2I;s6&>>Jb29Hy zyK`h5cYnBc!y!c<0m75-?YOVLZ~c+(e&3H$UdBZfD8{A! zqhear&y$G~N+VJ*q(`L3w;!9>Rfp230(_-sx4(cotr%k3ZpMbV z8V9l2-C3bMJ7S%{_;jR{#S- z!}?yJC3jf2Z+x5=zJ#b@UHfIc+xzc9mcvOvs`M&BIH_B@>dKSU2`pgW&OOn0jY#_G z5g5ufzGrcn3?76*tEJzt;)t;o5f!)nvBeo$$oO^jZ8a9QIVkwlOc2rtCy@zVf%9ib z3b@zQ)(h{#8G8fHj zSRoRTPzANKx96PvZsbhgu1;_s>#Fz&Q)0{X!?ri(wk}!#6nGe6J>9)IA^dl8r^yIR zkxNaopU}HNYx3bVNdh1Qjb|mU2)|AF&=d}1^-oGE7!LqfwArFfZ; zM$55}#Uuww75O?;no-y=k3d^6>Bley_x~e{$BHg{muB|7PA)|GX>jkCj0+Xm{JfVWy z4Iu#aLmIG;xTnw;&VliP3%I>bu9i6xK#`-L<*rJVZ27J`TpFk(=SPRaDJ*NLj%Id& zpi)fsigz@WvNBeNQh~{XgUYBF;AFWjlUlE% zoQ3TK7zPQG(bbOh#2hh+xTcYYoF<&?WNHZ_Fu+x84n>|W(SuX$-z?C^fykAF3^+R} zwn@5=k>x7%un-CIq*|xoL6eKPFLDHzFs3l>hCQc+2YUcE{eV3a0F0hFA%8fTg|dhC z=v}UGDkJAgO{5DdO;VVgrR0%1h&W~W!pX2nn#ldsEEh3e+&{ukdU;#H{xbWDhajAz z6C)1XB9EKkYh{cfe-zO{t`|yW8vCIoi!MK2bu%O0kIGqd`_!#dubr8pTNcf^bJY2x0G2f zW|jvtZ8NP%n4bOW^o{Acz7H(Lp{(3F+k>oa6Ww!;g+d|p6 zD=>eq<11&`_e!y#(!XhbJzgDJ%5Dy1H_PAmf2H5A^?M&w?0CqPE^(VgZWG=m%h@`o z3#}_&TGxPU=I2X;>zbDGo5cJkyhoO^6)(<6ug&f27SiD`%p-wEQ1G6=L@1NHewulgvfX%DMu zFVz&$GIFH%6@7K{{QPl5+j)RuZ8}J?GCDP(^jw01yd{ejPnP*g7A@@h?X;X%M{kV2 zGQN~nBBqrD)5>PFxYE;XnJs^1Hvwb+t$tr$FsE{+U^>gcvizpJ_q@ZXa>ou z9%6OApwyX_H&@|nU$j&{qqxAq%YiE|p_r@E7?Xt@+cce2%vE(4`L3uBv0}PeF^40? z%nshT=F5112@-v6oZ1v<><#pt3Y>O`eS?ACp}>$kF!>VlnO5(kTraElQ+AVT*5L;< z2g%o|VT078d6A-YYU~tvP;;IYJirR(m4c<2d3@1Qif23JXM;nwx_1iRE|~AW*BrEU z+|Lkg2l3^an7$#jZO``$zE^PXVBpBn;I^K?vEvW69T(Sb4xAaCO7KKT=Jl_1hbn5{NqIXZ(Aaj*9<11Zzd@|%n&}8+ zm5S!FpI8bWuCw{AcgAK-AFbuyJaFg0>z)3p`4O?QRb1PO>aHz+^YER+{*K_<#`&w_ z+C8($$lqG-^N8HW+0+lS)_zo4?oVCNh!xuxT7so}W)IXNkIaW^x4kp|_V~iseS5I>NT9b*tnI@?^xa~5)z4V*yKn9GS$)01 zESvvSFstcdcEQ}%V0NirEoR$bhpOslJAFxb1Qn&!ZhGg`+ou-N?yG|}2k##hYr1DU z=5~wOm7$GW->ZDLGO+7Npr<#uu`h7qMRDVcvmJrLS~0sWl*jqDus5YIE^H2#G(X5| z4%Ifz8RlLR^Q(aK^R1zVruPcoEm-Kj-yCe{4jk-%Q+f0(yERKMlDly_4WcHOrI>$~n>66=qk zBGY1iO(?(I--rs$S$@iu`1bk7#nN3tZujf~Ag#GguXXtjh}ku-b)gpd>u!5)c>={7 z=i3+awud(Fc<tGQJp+V3mlCQ1rhVu1+ou=u#hR8tb!(s%)AQs~ zU%%MbA9!&{?7I-?bp~w1chVj{B^O)OTpGHOSC6QD4Zgm&PTxH}pD&he50vgeJF@?{ z@JEG#V`szzX9N4siPrwWCD$yy9$s3pRI*hp*}9N>PxrmG!IF+!U9;_ToBt$_3zgTr z<+⪚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 g9jHy#W 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.sasl_authenticated = False + 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 + + # 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_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 get_player_coins(self, player): + """Get player coins with safe access""" + return player.get('coins', 0) + + def set_player_coins(self, player, amount): + """Set player coins safely""" + player['coins'] = max(0, amount) + + def add_player_coins(self, player, amount): + """Add coins to player safely""" + current_coins = self.get_player_coins(player) + self.set_player_coins(player, current_coins + amount) + + def deduct_player_coins(self, player, amount): + """Deduct coins from player safely""" + current_coins = self.get_player_coins(player) + self.set_player_coins(player, current_coins - amount) + + 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): + """Send message to user respecting their notice/private message preferences""" + player = self.get_player(f"{nick}!*@*") + + # Default to channel notices if player not found or no settings + use_notices = True + if player and 'settings' in player: + use_notices = player['settings'].get('notices', True) + + if use_notices: + # Send to channel + self.send_message(channel, message) + else: + # 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 + + async def connect(self): + 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!") + + # Check if SASL is enabled + sasl_config = self.config.get('sasl', {}) + if sasl_config.get('enabled', False): + self.logger.info("SASL authentication enabled") + # Request SASL capability + self.send_raw('CAP LS 302') + else: + # Standard registration without SASL + await self.register_user() + + async def register_user(self): + """Register the user with the IRC server""" + 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') + + # 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_sasl_auth(self): + """Handle SASL PLAIN authentication""" + sasl_config = self.config.get('sasl', {}) + username = sasl_config.get('username', '') + password = sasl_config.get('password', '') + + if not username or not password: + self.logger.error("SASL enabled but username/password not configured") + await self.register_user() + return + + self.logger.info(f"Authenticating via SASL as {username}") + + # SASL PLAIN authentication format: authzid \0 authcid \0 password + # For most IRC networks: "" \0 username \0 password + auth_string = f"\0{username}\0{password}" + auth_b64 = base64.b64encode(auth_string.encode()).decode() + + self.logger.debug(f"SASL auth string length: {len(auth_b64)} chars") + + self.send_raw('AUTHENTICATE PLAIN') + # Split long auth strings into 400-byte chunks as per IRC spec + if len(auth_b64) <= 400: + self.send_raw(f'AUTHENTICATE {auth_b64}') + else: + while auth_b64: + chunk = auth_b64[:400] + auth_b64 = auth_b64[400:] + self.send_raw(f'AUTHENTICATE {chunk}') + if not auth_b64: + break + + async def attempt_nickserv_auth(self): + """Attempt NickServ identification as fallback""" + 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 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 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: + return self.players[nick] + + # Create new player + player_data = { + 'xp': 0, + 'coins': 0, # Add missing coins field + 'caught': 0, + 'befriended': 0, # Separate counter for befriended ducks + 'missed': 0, + 'ammo': 6, + 'max_ammo': 6, + 'chargers': 2, + 'max_chargers': 2, + 'accuracy': 65, + 'reliability': 70, # Gun reliability percentage + 'weapon': 'pistol', # Default weapon + 'gun_confiscated': False, + 'explosive_ammo': False, + 'settings': { + 'notices': True, # True for notices, False for private messages + 'private_messages': False + }, + # New advanced stats + '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, + # 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 + self.save_database() + 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): + if not user: + return + + nick = user.split('!')[0] + nick_lower = nick.lower() + + # Check if user is ignored + if nick_lower in self.ignored_nicks: + 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() + + # 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 + + 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 + + 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 + + # 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) + + 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') + + # 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': + 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'] + + # Befriend the duck - gives friendship XP and coins + player['befriended'] = player.get('befriended', 0) + 1 # Track befriended separately + player['xp'] += 8 # Less XP than shooting but still good + coins_earned = random.randint(1, 2) # 1-2 coins per befriended duck + self.add_player_coins(player, coins_earned) # Safe coins access + + # Mark duck as befriended (dead) + target_duck['alive'] = False + + # Lucky items with luck bonus (same chance as shooting) + lucky_items = ["four-leaf clover", "rabbit's foot", "horseshoe", "lucky penny", "magic feather"] + base_luck_chance = 5 + player['luck'] # 5% base + luck bonus + 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 "" + + 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* [8 xp] [+{coins_earned} coins]{lucky_text}{duck_count_text}") + + # Save to database after befriending + 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 cmd == '!shop': + await self.handle_shop(nick, channel, user) + elif cmd.startswith('!buy '): + item = cmd[5:].strip() + await self.handle_buy(nick, channel, item, user) + elif cmd.startswith('!trade '): + parts = 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 cmd.startswith('!steal '): + target_nick = cmd[7:].strip() + await self.handle_steal(nick, channel, user, target_nick) + elif cmd.startswith('!give '): + parts = cmd[6:].split() + if len(parts) >= 3: + target_nick, item, amount = parts[0], parts[1], parts[2] + await self.handle_give(nick, channel, user, target_nick, item, amount) + else: + self.send_message(channel, f"{nick} > Usage: !give ") + elif cmd.startswith('!rearm ') and self.is_admin(user): # Admin only + # Allow rearming other players or self + target_nick = 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 cmd.startswith('!setcoins ') and self.is_admin(user): # Admin only + parts = cmd[10:].split() + if len(parts) >= 2: + target_nick, amount = parts[0], parts[1] + await self.handle_setcoins(nick, channel, target_nick, amount) + elif cmd.startswith('!ban ') and self.is_admin(user): # Admin only + target_nick = cmd[5:].strip() + await self.handle_ban(nick, channel, target_nick) + elif cmd.startswith('!reset ') and self.is_admin(user): # Admin only + target_nick = 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 cmd.startswith('!resetdb confirm ') and self.is_admin(user): # Admin only + confirmation = 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 cmd.startswith('!level '): + # Show specific player's level info + target_nick = 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 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.startswith('!ignore ') and self.is_admin(user): # Admin only + target_nick = cmd[8:].strip().lower() + await self.handle_ignore(nick, channel, target_nick) + elif cmd.startswith('!delignore ') and self.is_admin(user): # Admin only + target_nick = cmd[11:].strip().lower() + await self.handle_delignore(nick, channel, target_nick) + elif cmd.startswith('!giveitem ') and self.is_admin(user): # Admin only + parts = 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 cmd.startswith('!givexp ') and self.is_admin(user): # Admin only + parts = 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 ") + + 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) + + 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 + target_player['gun_confiscated'] = False + target_player['ammo'] = target_player['max_ammo'] # Full ammo when rearmed + if target_nick_lower == nick.lower(): + self.send_message(channel, f"{nick} > {self.colors['green']}Admin command: Gun restored with full ammo.{self.colors['reset']}") + else: + self.send_message(channel, f"{nick} > {self.colors['green']}Admin command: {target_nick}'s gun restored with full ammo.{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 coins (friendly gesture) + rearm_cost_coins = 10 + current_coins = self.get_player_coins(player) + if current_coins < rearm_cost_coins: + self.send_message(channel, f"{nick} > You need {rearm_cost_coins} coins to rearm {target_nick} (you have {current_coins} coins)") + return + + self.deduct_player_coins(player, rearm_cost_coins) + 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_coins} coins] {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 !trade ", + 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 !setcoins !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 (PRIVMSG or NOTICE)""" + 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: + player['settings'] = { + 'notices': True + } + + output_type = output_type.upper() + + if output_type == 'PRIVMSG': + player['settings']['notices'] = False + self.save_database() + self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}PRIVMSG{self.colors['reset']} (private messages)") + + elif output_type == 'NOTICE': + player['settings']['notices'] = True + self.save_database() + self.send_message(channel, f"{nick} > Output mode set to {self.colors['cyan']}NOTICE{self.colors['reset']} (channel notices)") + + else: + current_mode = 'NOTICE' if player['settings']['notices'] else 'PRIVMSG' + self.send_message(channel, f"{nick} > Current output mode: {self.colors['cyan']}{current_mode}{self.colors['reset']} | Usage: !output PRIVMSG or !output NOTICE") + + 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 + + # Show compact shop in eggdrop style + shop_msg = f"[Duck Hunt] Purchasable items: 1-Extra bullet(7xp) 2-Extra clip(20xp) 3-AP ammo(15xp) 4-Explosive ammo(25xp) 5-Repurchase gun(40xp) 6-Grease(8xp) 7-Sight(6xp) 8-Infrared detector(15xp) 9-Silencer(5xp) 10-Four-leaf clover(13xp) 11-Shotgun(100xp) 12-Assault rifle(200xp) 13-Sniper rifle(350xp) 14-Auto shotgun(500xp) 15-Sand(7xp) 16-Water bucket(10xp) 17-Sabotage(14xp) 18-Life insurance(10xp) 19-Liability insurance(5xp) 20-Decoy(80xp) 21-Bread(50xp) 22-Duck detector(50xp) 23-Mechanical duck(50xp) | Syntax: !shop [id [target]]" + self.send_message(channel, f"{nick} > {shop_msg}") + self.send_message(channel, f"{nick} > Your XP: {player['xp']} | Use !buy to purchase") + + async def handle_buy(self, nick, channel, item, user): + player = self.get_player(user) + if not player: + self.send_message(channel, f"{nick} > Player data not found!") + return + + # Eggdrop-style shop items with XP costs + shop_items = { + '1': {'name': 'Extra bullet', 'cost': 7, 'effect': 'ammo'}, + '2': {'name': 'Extra clip', 'cost': 20, 'effect': 'max_ammo'}, + '3': {'name': 'AP ammo', 'cost': 15, 'effect': 'accuracy'}, + '4': {'name': 'Explosive ammo', 'cost': 25, 'effect': 'explosive'}, + '5': {'name': 'Repurchase confiscated gun', 'cost': 40, 'effect': 'gun'}, + '6': {'name': 'Grease', 'cost': 8, 'effect': 'reliability'}, + '7': {'name': 'Sight', 'cost': 6, 'effect': 'accuracy'}, + '8': {'name': 'Infrared detector', 'cost': 15, 'effect': 'detector'}, + '9': {'name': 'Silencer', 'cost': 5, 'effect': 'silencer'}, + '10': {'name': 'Four-leaf clover', 'cost': 13, 'effect': 'luck'}, + '11': {'name': 'Shotgun', 'cost': 100, 'effect': 'shotgun'}, + '12': {'name': 'Assault rifle', 'cost': 200, 'effect': 'rifle'}, + '13': {'name': 'Sniper rifle', 'cost': 350, 'effect': 'sniper'}, + '14': {'name': 'Automatic shotgun', 'cost': 500, 'effect': 'auto_shotgun'}, + '15': {'name': 'Handful of sand', 'cost': 7, 'effect': 'sand'}, + '16': {'name': 'Water bucket', 'cost': 10, 'effect': 'water'}, + '17': {'name': 'Sabotage', 'cost': 14, 'effect': 'sabotage'}, + '18': {'name': 'Life insurance', 'cost': 10, 'effect': 'life_insurance'}, + '19': {'name': 'Liability insurance', 'cost': 5, 'effect': 'liability'}, + '20': {'name': 'Decoy', 'cost': 80, 'effect': 'decoy'}, + '21': {'name': 'Piece of bread', 'cost': 50, 'effect': 'bread'}, + '22': {'name': 'Ducks detector', 'cost': 50, 'effect': 'duck_detector'}, + '23': {'name': 'Mechanical duck', 'cost': 50, 'effect': 'mechanical'} + } + + 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 + + # Purchase the item + player['xp'] -= cost + effect = shop_item['effect'] + + # Apply item effects + if effect == 'ammo': + player['ammo'] = min(player['max_ammo'], player['ammo'] + 1) + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +1 ammo") + elif effect == 'max_ammo': + player['max_ammo'] += 2 + player['ammo'] = player['max_ammo'] # Fill ammo + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +2 max ammo") + elif effect == 'accuracy': + player['accuracy'] = min(95, player['accuracy'] + 3) + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +3% accuracy") + elif effect == 'explosive': + player['explosive_ammo'] = True + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Explosive rounds loaded") + elif effect == 'gun': + player['gun_confiscated'] = False + player['jammed'] = False + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Gun restored and loaded") + elif effect == 'reliability': + player['reliability'] = min(95, player['reliability'] + 5) + player['grease'] = player.get('grease', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +5% reliability") + elif effect == 'detector': + player['detector'] = player.get('detector', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Infrared detection enabled") + elif effect == 'silencer': + player['silencer'] = player.get('silencer', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Silent shooting enabled") + elif effect == 'luck': + player['luck'] = player.get('luck', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +1 luck point") + elif effect == 'shotgun': + player['weapon'] = 'shotgun' + player['accuracy'] = min(95, player['accuracy'] + 10) + player['max_ammo'] = max(player['max_ammo'], 8) + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +10% accuracy, 8 ammo capacity") + elif effect == 'rifle': + player['weapon'] = 'rifle' + player['accuracy'] = min(95, player['accuracy'] + 15) + player['max_ammo'] = max(player['max_ammo'], 12) + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +15% accuracy, 12 ammo capacity") + elif effect == 'sniper': + player['weapon'] = 'sniper' + player['accuracy'] = min(95, player['accuracy'] + 25) + player['max_ammo'] = max(player['max_ammo'], 6) + player['ammo'] = player['max_ammo'] + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +25% accuracy, 6 ammo capacity") + elif effect == 'auto_shotgun': + player['weapon'] = 'auto_shotgun' + player['accuracy'] = min(95, player['accuracy'] + 20) + player['max_ammo'] = max(player['max_ammo'], 15) + player['ammo'] = player['max_ammo'] + player['explosive_ammo'] = True + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +20% accuracy, 15 ammo, explosive rounds!") + elif effect == 'sunglasses': + player['sunglasses'] = player.get('sunglasses', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +5% accuracy in bright conditions") + elif effect == 'clothes': + player['clothes'] = player.get('clothes', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Backup outfit equipped") + elif effect == 'brush': + player['reliability'] = min(95, player['reliability'] + 3) + player['brush'] = player.get('brush', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +3% reliability") + elif effect == 'mirror': + player['mirror'] = player.get('mirror', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! +3% accuracy") + elif effect == 'sand': + player['sand'] = player.get('sand', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Pocket sand ready") + elif effect == 'water': + player['water'] = player.get('water', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Fire extinguisher ready") + elif effect == 'sabotage': + player['sabotage'] = player.get('sabotage', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Sabotage kit ready") + elif effect == 'life_insurance': + player['life_insurance'] = player.get('life_insurance', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Death protection active") + elif effect == 'liability': + player['liability'] = player.get('liability', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Accident coverage active") + elif effect == 'decoy': + player['decoy'] = player.get('decoy', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Decoy duck ready") + elif effect == 'bread': + player['bread'] = player.get('bread', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Duck attractant ready") + elif effect == 'duck_detector': + player['duck_detector'] = player.get('duck_detector', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Advanced duck detection enabled") + elif effect == 'mechanical': + player['mechanical'] = player.get('mechanical', 0) + 1 + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Mechanical duck ready") + elif effect == 'water': + # Utility item + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Emergency water ready") + elif effect == 'sabotage': + # Offensive against others + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Sabotage kit ready") + elif effect == 'life_insurance': + # Protection from death + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Protected from consequences") + elif effect == 'liability': + # Protection from accidents + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Accident coverage active") + elif effect == 'decoy': + # Special duck interaction + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Duck decoy deployed") + elif effect == 'bread': + # Attract ducks + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Duck attraction increased") + elif effect == 'duck_detector': + # Advanced detection + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Enhanced duck detection") + elif effect == 'mechanical': + # Mechanical duck + self.send_message(channel, f"{nick} > Purchased {shop_item['name']}! Robotic companion acquired") + + # Save to database after purchase + self.save_player(user) + + 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': + current_coins = self.get_player_coins(player) + if current_coins < amount: + self.send_message(channel, f"{nick} > You don't have {amount} coins!") + return + self.deduct_player_coins(player, 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_steal(self, nick, channel, user, target_nick): + """Attempt to steal from another player""" + player = self.get_player(user) + if not player: + return + + # Cooldown check (simplified) + steal_chance = random.randint(1, 100) + + if steal_chance <= 30: # 30% success rate + stolen_coins = random.randint(1, 10) + self.add_player_coins(player, stolen_coins) + player['xp'] -= 2 # Penalty for stealing + self.send_message(channel, f"{nick} > You successfully stole {stolen_coins} coins from {target_nick}! [-2 xp for being a thief]") + else: + penalty = random.randint(5, 15) + self.deduct_player_coins(player, penalty) + player['xp'] -= 5 + self.send_message(channel, f"{nick} > You got caught stealing! Lost {penalty} coins and 5 xp!") + + self.save_player(user) + + async def handle_give(self, nick, channel, user, target_nick, item, amount): + """Give items to 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 item == 'coins': + current_coins = self.get_player_coins(player) + if current_coins < amount: + self.send_message(channel, f"{nick} > You don't have {amount} coins!") + return + self.deduct_player_coins(player, amount) + self.send_message(channel, f"{nick} > Gave {amount} coins to {target_nick}! [+1 xp for generosity]") + player['xp'] += 1 + + elif item == 'ammo': + if player['ammo'] < amount: + self.send_message(channel, f"{nick} > You don't have {amount} ammo!") + return + player['ammo'] -= amount + self.send_message(channel, f"{nick} > Gave {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 + player['chargers'] -= amount + self.send_message(channel, f"{nick} > Gave {amount} chargers to {target_nick}!") + + else: + self.send_message(channel, f"{nick} > Can't give '{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},Coins:{data['coins']})") + + 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_setcoins(self, nick, channel, target_nick, amount): + """Admin command to set player's coins""" + try: + amount = int(amount) + except ValueError: + self.send_message(channel, f"{nick} > Amount must be a number!") + return + + target_nick_lower = target_nick.lower() + if target_nick_lower in self.players: + self.players[target_nick_lower]['coins'] = amount + self.send_message(channel, f"{nick} > Set {target_nick}'s coins to {amount}") + self.save_database() + else: + self.send_message(channel, f"{nick} > Player {target_nick} not found!") + + 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] = { + 'coins': 100, '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") + + # 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': + if len(params) >= 2 and params[1] == 'LS': + # Check if SASL is available + caps = trailing.split() if trailing else [] + if 'sasl' in caps: + self.logger.info("SASL capability available") + self.send_raw('CAP REQ :sasl') + else: + self.logger.warning("SASL not available, proceeding without authentication") + self.send_raw('CAP END') + await self.register_user() + elif len(params) >= 2 and params[1] == 'ACK': + # SASL capability acknowledged + if 'sasl' in trailing: + self.logger.info("SASL capability acknowledged") + await self.handle_sasl_auth() + elif len(params) >= 2 and params[1] == 'NAK': + # SASL capability not acknowledged + self.logger.warning("SASL capability denied, proceeding without authentication") + self.send_raw('CAP END') + await self.register_user() + + elif command == 'AUTHENTICATE': + if params and params[0] == '+': + # Server is ready for authentication + self.logger.info("Server ready for SASL authentication") + + elif command == '903': # SASL auth successful + self.sasl_authenticated = True + self.logger.info("SASL authentication successful!") + self.send_raw('CAP END') + await self.register_user() + + elif command == '904': # SASL auth failed + self.logger.error("SASL authentication failed! (904 - Invalid credentials or account not found)") + self.logger.info("Falling back to NickServ identification...") + self.logger.error(f"Attempted username: {self.config.get('sasl', {}).get('username', 'N/A')}") + self.send_raw('CAP END') + await self.register_user() + # Will attempt NickServ auth after registration + + elif command == '905': # SASL auth too long + self.logger.error("SASL authentication string too long! (905)") + self.logger.info("Falling back to NickServ identification...") + self.send_raw('CAP END') + await self.register_user() + + elif command == '906': # SASL auth aborted + self.logger.error("SASL authentication aborted! (906)") + self.logger.info("Falling back to NickServ identification...") + self.send_raw('CAP END') + await self.register_user() + + elif command == '907': # SASL auth already completed + self.logger.info("SASL authentication already completed") + self.send_raw('CAP END') + await self.register_user() + + elif command == '001': # Welcome + self.registered = True + auth_status = " (SASL authenticated)" if self.sasl_authenticated else "" + self.logger.info(f"Successfully registered!{auth_status}") + + # If SASL failed, try NickServ identification + if not self.sasl_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/src/__pycache__/auth.cpython-312.pyc b/src/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d4ec72aa29401786154e869100f4dc58f50a2db GIT binary patch literal 3306 zcmbUjOKcn0@$Hw(kF=DQ3KiM4Wm&ew$Pw+>QkvE+T32u`t%}AfY6wLaEAG;y#E<%R z<(R?>@xdr)v_PC*q(cro6%=%+4n5?MgOUPmfL=t%1(|IMxacW2rYuk(r_Q|P$`ow~ zXrIKHH#6_eo8Ozae;F8v5EzMne44wh5b{qXf+2P%of|OOCMGet0$KJkx6A>}7x<#E zEO3M;Zxd5^L`-py_jfIe6)q)xiNs=x^W=q%%-X`Oj4$*dI~k^(-vQVr7D1iMyvbQY zmN)rF;<9KCo1!I|68vOK&dO%!k+d9QJmr|LWEC?EtHWjlc(^_azZm>vOUXve0gy#b z$dIp?gTRlnwX!8;MNq;c z{cf3>V1!X{JegX~1}$YU1i?UMEF>SpWShAB!x85=2&=~d2$mBlvc0Z+i+7}ezlwVL zhRM6!YPgRH{OoAxZdGonBkylM-u$(@vvjClI8-js zK~PA~0LXMh1ye$B&QdxL6G$mpJx(Z!{QLr-nVEzTX`Otv7X5E$%bIBeGve}CV8frOf{0J z`t<(7QSz|Ar~N@MIw{qE7&{9_f$<;Jto@zyskw-TeRv*Rt8 zkDY6sx%8E)j7F-lc7lWl-VZ$vHI=gsIyeuU4=c~P#l+lV8IVkOH;@hM}P@PNiR#J7wTrp z#-e6P^1E3KizRs3Hj_Qc=@sA+_KyJI`}4~7)CW_|;meKT%e4nb!&j@J&(&8jF;TS{ zJ>Q6)uPODZ{dD8CTL*7{cKeebKaDQ6UZ1NjG-H#E*ko;~5qqtrC0lC3PeAX3bKJ+i z1!w(l34lkcs|g+ke$fNK;^7JREN7SZgMu~PeUp9n6CeyqQ_q$uyHcCyd&2Ot1=mpx z_C*MXsx{TAhC0<$XBz5EePMs%NS!-W=3aCT&BJVW&H(`DFyAoiANPZ1yvs2M=p3v# zVRSri>%G}APh6^gAwCJV27np+#Aznejb!>zO&==hQ;fu-JIzQs_;T}I?mMIcf9yS> z-vli$Iv^Ly_sSd2=1@wS%i|Bf>Y;F{A9@F(-ZWsk$LNWMs0DvdJ=b9%u8|wwj?}g$@xm@rs z5q>DrYX~rM6fL6P2Cz%~1Mwq(;w@qTC-$EJJR)sgkfwL$I|R^I5mB0`W!ePLzQ)jj z)Iq8pb K|BC=%JO2x=nb>3i literal 0 HcmV?d00001 diff --git a/src/__pycache__/db.cpython-312.pyc b/src/__pycache__/db.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc7ff261b734427ae8832f46b55e18c4837c297c GIT binary patch literal 6838 zcmds5U2GHC6`tRCoH!1cK!Js1ST=xZmXzOy{A}tZ1`LUVY^Na+)HpK}aB$-6%w*YE z&I(mL5G^Q+S}F96N|g$tQmM3Usrs<6Pb>As*-*tD`heP}eS>7BXnE>6^Ov!mgbn3s zZiQeZ zO?RI2%yZjkx$Xbtb}ZtG1^WQt13TgYE!IHk3;`R3kK%NcAXljw#=b>Kw2yFIy(sJ8 z*VT)Dqh8E%FSyL`XI)@~ayN01P{^wkN%X_MvE*aApSVi;*>Fbbhy(+%ARh~~cLn*- z2|nBtgAU^F?ViDO}-W!E-VGY$Vhji1hPkg8lU+&BTP7(3=(1 z_%n~zm^2{>X}BaS%{s0~@@Qg2TF=a-i!yGm7*D8tEYuy0#sb}E`Oe^pKyO!!@92$0 zg5g-)t|?r3f`Lo5#^O^_LX_mebV3$aI5l%?`tnQHrKkxtu1<^@+bgaAD3KbM_)s_& zJQ<9XR*#RtX-nUCTc!OSK=S*{mDcn2qWnb%(X{qKs4^VHN*E?ZYYWb+!v}g}J)tn1 zY&V>K>Eedu^hkW|<5SbGYk*6N+gGx565Q*!Vq7lkKI|QnM#bdlrM;!i|DStwpOQH+ zGMY>JSQ5rn$xq7Yyc9HKyzBufyFp~CO{-X7ZS|Jdp!yn-uiQA@Kh^(|sCE0Edp1mm zrb6?cowJ^uF9^DJ3iWJppm13!3qW{yhRmdSWyNQzM={jR=6sCO9x?^C7es2 z@RfoKy%l)Bou;aha2ikCpl*ooLFR24;w-7MFr1!-(bN~EtZj6MsAUtl4wFcQ zGsi>WXfP7vdm?-!c(yCh0d_GQ>sdC3X#MJWyh(ok=Yg)?V3gn2R?oNj?=w2A@SHN5&LCt>ddP*i~b&tAb|(W>rU9t56ajNpZvAW_%{E)gJJqIk7f0R!DS6yWG592?l^ zDj*jO)akH&4w=B%GD{AT%b-B3H7Lj=ou%ciDr$kAhIMjh zeHp)5pjq1>`vBDjQWX4Nl52VeW!wvAdfJ#w#mp+?w1*= zSSF0iN?JCsq!bwrCx`rHN&r{n>!gopeN}`Dhi{JX8tPS2PLgB5nk5C0-Q}$ zeT(Fdq*Btf5!Y;oEg47e7AaT%bwW{0Pd+Acj`O%QBaY--~+qM zei*j(gH>#+>Dz%ZSR`*n11oQW{7ON`b&Een|AlQUB$H?cb)Y_h%xXZb$x47vtnAScE+<)pc5Cnw;1>W< z%v1~;1tU1qnTF%QctcC5p2F?0($flA^mRTJ%)rM&p?@dw4+7BsCO@#4QAd&)A93ezk ze>4I3Pn#O5hXq!s>x@zB`Q>mF@^<7|s+@j5xnY1M z0>lCeD_Haf>gXqRY~Vm))PbP9_7bhB-)|-A0M<%RGt;cVTteOoB=J0~3K%Fe$!6&+ z^BV5TTHJMrzD?cafxSXM=u4aY)%-FT?3igol43pYgw=Up3{I;sXyI}E3Nhm1~2c1Lx%f?XDwqusb3V0Gj2{&)HrJy2BE_6+L+AukefTme6Z^=-W~T@@_FCDe4jAeC(QLp^HoE+s-fBD=%dER z=e|-u+j^(b5!-&f;n-7Xzzux|KMF4O3AH=0pDQ3?_FM2Tq5kcdCXo~=h-2UEaRDRK z-m{(f-fChR4aO{>+Dvbe6kFRgw~!tgOG)scXLxVQK&=T66{A*5t-V$|1CY#SQ?(0= zIFW65w%>Tq01u)TQa2Ymd@@WG+*=& zh=YFe8@yNkDtyz&N7{H?E04!V(&Bgu^PYJ8!|_DQsF5q7ss6$xZ^hg$6m=-}q1cb2 z0mUH{jVO?=%WWX;6S@mB9?8%T$tcj;DeWMx6N?nZ?p)w_*0<0}(rn{GIl~@a@X&0C zTqvj67W=z-5o&4{JS@9w!OO6h$VCsuUL=j-aZqr*c!P{L01ls5)#Qkz&nI_c1qR;w tLSAt##!f$wPe2*IM=0pVU(h5;J|n!(h>B-K)e>`_BrlQ+g#M%V{vW?daE|~0 literal 0 HcmV?d00001 diff --git a/src/__pycache__/duckhuntbot.cpython-312.pyc b/src/__pycache__/duckhuntbot.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..707f459ef47c2a7408a20df29a8156e48a8b45c8 GIT binary patch literal 136 zcmX@j%ge<81oIys$^g-iK?FMZ%mNgd&QQsq$>_I|p@<2{`wUX^OI<%BKQ~oBIX@?< zQa`0MIXj~?uSCDNC>hR4$}iC?sQkrYlbfGXnv-f*#0u2M2*kx8#z$sGM#ds$APWHJ C=^v^9 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..f55da692325635830ff8cf0989eff64b3b182598 GIT binary patch literal 33969 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/items.cpython-312.pyc b/src/__pycache__/items.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba90264018454b2176b7d4aca9da0d43617bda70 GIT binary patch literal 2878 zcmbtWO>7&-6&{kiOH%w@Q9st7MaMB5=Eq9g90EHrK$)~8SfU(KN(~~wV#V2r>nyqK z>@pCE#9#oUb$|elFHwL9sC#JPAcywYW0Ok)#N0%E{9IPdLJv}6gn zWP$xS^WK~HhV#AmcD{|p1_(Si9(+~(2uA~m!T$i%+P@5&^Q1^cpqD+_@v+?NS0UuK|-jtUjeorl5RWRJpNlW3&uqonMo zKI*3dDo_z#X-g^xXpn|zm=4fLPa@DLjnOz*BxusL5vfcEVVt5vV27(DEl7uNk}rBe z%AwXsW|W6>&B{9~t%hc`BbUrt&8(+U(!`V4`SaiY*u4yct`7l^EhBNEuGTazYBlq^ z&LyTY-EMKQV(N8^`!^a~s%olXSKHyD$~4az{xNj+pRIe{A#@c$7z1cW=1hZXy`Ul2 z?yrAx1LLIoTLe7wb~MXsCex_rG2&W&_Lm?2dD}HkpxqUK3T}1(!9Rha_R1jLBz9kS zy+`A|A^zeq?^E=??>*mpzw z^$oG{Sf6^1iG7R}#3t9>(mA}mL^48w;byy-x2%^YkQsGq)_4$q^}5ZaOUp}3%LOhJ zv&B4@=9U+8`2r7Si%ZMJd@keXfr?>qftiM3AwB8zqf;|gv!=~dOk=GzLm@4z&AL5f zvC52YYc=a!qs0^Jnyq-D6trlM^%MHsQ>y2{sr3m!hx{)weEY_SH$GMGroK;{xg|J} z#K*teJb&l>r`7G$+53^RPHF_F-$kdKnEY{RvwWv~H?%#Rxp(RB^PkWEI@D!@}Lt>I)ka*STHViF7FHxdD4-`pHfAoP7YNk9eDzb9XWMm z)wBcJ!>WYTncEp8lDz8=i_?37{gJmh+}EKlxSx7^InW&Gf?gjy*af{l2>ov>SQelM zVh2pR-4CtARw^^ZBfrhqLej^GJ@BZ0I{G zqiK}6%(`r61dm>csx@_8<8q&7s5Jd;U1MB88r$(=b8Ssy>Di`XXtu?kht0W+alx{i z^^EAzb^*n&A-oR2gH}VU=&I39<~JIKY3c83>7v=JQ>*(Ekk2FVO;i;x_A!iwN-Nny zE?dlTskk~jn=kTk{@T^W<xaNVhC@*YvlouvuP~8sgq7p-VKXMsWZQmB=V`g z&`FK$MkKey2Z?;zl~04QlS+eJmOfbUNJ77dNW%KSXAd*v2uW}^aY>TkJ_2`>tpE@d z2Ey=$5(SM%PbAx~47hD9sumn?Nnfwp3@-=_ZwBrO_c-!O!adf+lVWp=tS#p^Y^_e= zI0aS_yoj@U1G=J1ViMvU48dil8G5g_4=g%{pU7gilrJi! zg}M0^9?f4{$rp0@oU)Xi@PK6M_z}g?Zw2U8tMnpffQEB&DRz6(s3d zXJizFyFp<{>MZWWdu4=N4uaW}0z!9?ekb6^>*{Xxy6NDCJ6zk3La%w>kc+GUEAR?r u{RUts;Pd(ZO=1s)8Q=6S0eHCPoA61y1mNNM5gY+Ld^zQl{!akh#P}K2d#-l? literal 0 HcmV?d00001 diff --git a/src/__pycache__/logging_utils.cpython-312.pyc b/src/__pycache__/logging_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5ce8b206c0baf9f9b12dd521ad799a289011525 GIT binary patch literal 1724 zcmZux-Af}^6u&cbCm+Uq_=R6xYu6~|Wn;VDmKG`9jmC}Gq{Ma?B4wE7j?r|INoOXd zx+A0@tUi>w55>T4p)CvT;zRp4^tCSrg@n5<6#CRRi$a%u>bWzCM)AO$k9*EN_sqG! zbN}@FI}p&%FaB8hSU~7+X0%9bDkphRb`e2@Q&Gl|T!sUiSNVdF5je!7IYfj_M6iPL zo>qc$xd*u!Wke!EM5kl5^JSj6Ho1&@jf=SdVUA>kqwbQdo2#;#ZI5&}jgu)*9ztOX zV#Q>5!V#Vb1QU_Ci2I2vBW$5%j(9db8N7wQ<}%_Uig;-trm319*YtvHnu@+I4llj; zcEJ{JIpU@x-gLyR!a5#ankqzGRGgW;^XVMLiDW!Q-S=;&lZoUU6=&1wRGNBX>BJ|A z*zE<1XOlCrEZs@-Qx0W_a!3SUNip&e|0Xx55k@e z!F08@Zi^QBwhJMP+vA+I4Q%m`k%i$C9-|)w=#P-3NcVv|B2rf@wUA0LQX!|9)T=6A zDQZzJC{#4cB}J#MWxI#a7i=i%GIi-nR?`U;v#h}sD@Br}Lcz#UuOwwv*)Sx@VAYH= zs=SIsSG0l>h3L=MqNJStVx?R(qlTW1s#-3$TFgmhb5%8Nl-Bfa*tOnw@#1bc#|OY= zEC731L5)!FuJ4g=H~1*{qpLnTRU4i9{rW-Z)|S`^_I&qX`$6@cTJZW7J___~=eCR0 zdk2B=meA<#-|4URRK+T(-rEm`_dVeg$cNy^Jd(uzSdwUmBo#DLR+-)@Nne&_yBCtA zvjXe_slWM~Sw&SPDZ=Rk;4)aRKh)V}=xm~F#uQ@$*vn1ySJ!YOG}P$1)&QkA%@_SS5Fv<>h0Y+r zw?$q7v~&1eIoB;92`0mgO#sc-Dz=2YzqNiYE!>LWkj3YN=bn~k2^S&&w3q1|#2mas z7B6{Q5f*R5cztqdd{Q>D=4wHS7@v)fPo4odV!RJ#K`{(D2flcugSwlLqh7%J0>BUO z?O!x?MK0Wxi$n!eFxCv}GEAbCP5TmQr=gf5&>6?Ek9t@}r6{*kJA z;15^gM}fY2V5}Axdm8&?{-^naK(sP<6dKuoJ#i44uOu4SSH}Z2JWw4x#A6NIQOA8X z+_#fE#3NumOnsBunLWfqjsDjv@k89(@O4!_vH=+J=x@M@dKeh>E|-g0Q`1yKXQ-pz zvqm^5XRowf=`-Y@mkq!x&$Kf{ALuv1WH2$wOco bzKK%=#_wf(L%j$^gi5WYNDqpjBFJ`Uo85MI6K6Ks$fiv} zaAOayr$#-PW2FCuS1*NnSQkBc>P-lE$jP_c2Bh?Z_lEc0_r3WB=36F{0>q&0$QTBntYj^?;QWVtTBpOQ?B1Zo&CS;K&!4O>7_@9Nd2vj&Y?XoK_P?16& zHX`*IP>G5M!b$Tfo=wsOD!cFk*f?%JPa&UCKc9fdjk zrQztI%xk9Mgrce0#Y8BXhOLE(8q?N|t#HI)ih-RsHNp(4xc1D_I9DnfyM*G%9pA&$f*>>2 zS_;Opt>qw@ezUW?3vc!A{np)|`KgZf>00;s$64>8Kj&X> zKklvJrRl7HqqEdqK1`PmrBWa5z-p1i2EPEij3vur8jReus 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] diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..771b9af --- /dev/null +++ b/src/db.py @@ -0,0 +1,97 @@ +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/src/duckhuntbot.py b/src/duckhuntbot.py new file mode 100644 index 0000000..e69de29 diff --git a/src/game.py b/src/game.py new file mode 100644 index 0000000..ffd02f5 --- /dev/null +++ b/src/game.py @@ -0,0 +1,566 @@ +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/src/items.py b/src/items.py new file mode 100644 index 0000000..f579bf1 --- /dev/null +++ b/src/items.py @@ -0,0 +1,124 @@ +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/src/logging_utils.py b/src/logging_utils.py new file mode 100644 index 0000000..439c6d6 --- /dev/null +++ b/src/logging_utils.py @@ -0,0 +1,28 @@ +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/src/utils.py b/src/utils.py new file mode 100644 index 0000000..6b04233 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,11 @@ +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/test_bot.py b/test_bot.py new file mode 100644 index 0000000..0515ae4 --- /dev/null +++ b/test_bot.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Test script for DuckHunt Bot +Run this to test the bot locally +""" + +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 duckhuntbot import IRCBot + +async def test_bot(): + """Test the bot initialization and basic functionality""" + try: + # Load config + with open('config.json') as f: + config = json.load(f) + + # Create bot instance + bot = IRCBot(config) + print("✅ Bot initialized 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!") + + print("🦆 DuckHunt Bot is ready to deploy!") + return True + + except Exception as e: + print(f"❌ Error: {e}") + return False + +if __name__ == '__main__': + success = asyncio.run(test_bot()) + if not success: + sys.exit(1) diff --git a/test_connection.py b/test_connection.py new file mode 100644 index 0000000..e69de29