From 29aa8537235201e3fcc06b05afe82a62cd330560 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 4 Jun 2026 03:08:01 +0100 Subject: [PATCH] feat: character sharing/borrowing, impersonation, conflict resolution, W.Rank per char, autocomplete, UI improvements --- .env | 7 +- data/characters.json | 4 +- data/poll-state.json | 26 +++++ data/wrank.json | 91 +++++++++++++++- emoji-uploads/luminous_bringer.png | Bin 0 -> 22299 bytes emoji-uploads/storm_bringer.png | Bin 0 -> 11300 bytes emoji-uploads/wrank_1.png | Bin 0 -> 938 bytes emoji-uploads/wrank_1_gold.png | Bin 0 -> 945 bytes emoji-uploads/wrank_2.png | Bin 0 -> 1981 bytes emoji-uploads/wrank_2_gold.png | Bin 0 -> 2038 bytes emoji-uploads/wrank_3.png | Bin 0 -> 2483 bytes emoji-uploads/wrank_3_gold.png | Bin 0 -> 2572 bytes emoji-uploads/wrank_4.png | Bin 0 -> 1395 bytes emoji-uploads/wrank_4_gold.png | Bin 0 -> 1407 bytes emoji-uploads/wrank_5.png | Bin 0 -> 1850 bytes emoji-uploads/wrank_5_gold.png | Bin 0 -> 1932 bytes emoji-uploads/wrank_down.png | Bin 0 -> 283 bytes emoji-uploads/wrank_down_1.png | Bin 0 -> 956 bytes emoji-uploads/wrank_down_2.png | Bin 0 -> 2022 bytes emoji-uploads/wrank_down_3.png | Bin 0 -> 2553 bytes emoji-uploads/wrank_down_4.png | Bin 0 -> 1420 bytes emoji-uploads/wrank_down_5.png | Bin 0 -> 1923 bytes emoji-uploads/wrank_up.png | Bin 0 -> 269 bytes emoji-uploads/wrank_up_1.png | Bin 0 -> 943 bytes emoji-uploads/wrank_up_2.png | Bin 0 -> 2174 bytes emoji-uploads/wrank_up_3.png | Bin 0 -> 2652 bytes emoji-uploads/wrank_up_4.png | Bin 0 -> 1497 bytes emoji-uploads/wrank_up_5.png | Bin 0 -> 1899 bytes messages/emojis.json | 80 +++++++------- scripts/upload-emojis.ts | 145 ++++++++++++++++++++++---- src/commands/tg.ts | 18 +++- src/handlers/buttons.ts | 97 ++++++++++++++++- src/handlers/interactions.ts | 37 ++++++- src/handlers/modals.ts | 156 ++++++++++++++++++++++++++++ src/index.ts | 74 ++++++++----- src/scheduler.ts | 33 ++++++ src/subcommands/poll/lock.ts | 27 +++-- src/subcommands/poll/reload.ts | 96 +++++++++++++++-- src/subcommands/poll/reload.ts.bak | 12 +++ src/subcommands/rank/post.ts | 50 ++++++--- src/subcommands/rank/post.ts.bak | 39 +++++++ src/subcommands/score/submitCore.ts | 86 +++++++++++++++ src/subcommands/switch.ts | 30 ++++-- src/systems/charSelect.ts | 100 ++++++++++++++++++ src/systems/conflict.ts | 36 +++---- src/systems/format.ts | 58 ++++++++++- src/systems/poll.ts | 125 +++++++++++++++------- src/systems/pollPersistence.ts | 88 ++++++++++++++++ src/systems/slots.ts | 53 ++++++---- src/types.ts | 1 + 50 files changed, 1342 insertions(+), 227 deletions(-) create mode 100644 data/poll-state.json create mode 100644 emoji-uploads/luminous_bringer.png create mode 100644 emoji-uploads/storm_bringer.png create mode 100644 emoji-uploads/wrank_1.png create mode 100644 emoji-uploads/wrank_1_gold.png create mode 100644 emoji-uploads/wrank_2.png create mode 100644 emoji-uploads/wrank_2_gold.png create mode 100644 emoji-uploads/wrank_3.png create mode 100644 emoji-uploads/wrank_3_gold.png create mode 100644 emoji-uploads/wrank_4.png create mode 100644 emoji-uploads/wrank_4_gold.png create mode 100644 emoji-uploads/wrank_5.png create mode 100644 emoji-uploads/wrank_5_gold.png create mode 100644 emoji-uploads/wrank_down.png create mode 100644 emoji-uploads/wrank_down_1.png create mode 100644 emoji-uploads/wrank_down_2.png create mode 100644 emoji-uploads/wrank_down_3.png create mode 100644 emoji-uploads/wrank_down_4.png create mode 100644 emoji-uploads/wrank_down_5.png create mode 100644 emoji-uploads/wrank_up.png create mode 100644 emoji-uploads/wrank_up_1.png create mode 100644 emoji-uploads/wrank_up_2.png create mode 100644 emoji-uploads/wrank_up_3.png create mode 100644 emoji-uploads/wrank_up_4.png create mode 100644 emoji-uploads/wrank_up_5.png create mode 100644 src/handlers/modals.ts create mode 100644 src/scheduler.ts create mode 100644 src/subcommands/poll/reload.ts.bak create mode 100644 src/subcommands/rank/post.ts.bak create mode 100644 src/subcommands/score/submitCore.ts create mode 100644 src/systems/charSelect.ts create mode 100644 src/systems/pollPersistence.ts diff --git a/.env b/.env index 8705b81..b0d98a2 100644 --- a/.env +++ b/.env @@ -5,9 +5,12 @@ SCORE_CHANNEL_ID=1511006435079884991 CLIENT_ID=1510959814623105044 GUILD_ID=1511006171681652858 EPHEMERAL_DELETE_MS=0 -POLL_EPHEMERAL_ENABLED=true # voting messages (disabled during testing) +POLL_EPHEMERAL_ENABLED=false # voting messages (disabled during testing) COMMAND_EPHEMERAL_ENABLED=true # command outputs (always on) AUTO_VOTE_ON_CONFLICT_SWITCH=true IMPERSONATE_RESET_ON_POLL=false IMPERSONATE_INDICATOR=true -RECLAIM_NOTIFY_BORROWER=true \ No newline at end of file +RECLAIM_NOTIFY_BORROWER=true + +# Emoji upload servers +EMOJI_DONOR_GUILDS=1511903882224336926,1511904145810915449 \ No newline at end of file diff --git a/data/characters.json b/data/characters.json index db2f30c..9357e9b 100644 --- a/data/characters.json +++ b/data/characters.json @@ -6,7 +6,7 @@ "class": "FB", "level": 79, "nation": "Procyon", - "active": true, + "active": false, "sharedWith": [ "invicjusz" ] @@ -16,7 +16,7 @@ "class": "WI", "level": 79, "nation": "Procyon", - "active": false, + "active": true, "sharedWith": [ "invicjusz" ] diff --git a/data/poll-state.json b/data/poll-state.json new file mode 100644 index 0000000..7700aba --- /dev/null +++ b/data/poll-state.json @@ -0,0 +1,26 @@ +[ + { + "messageId": "1511906795667456040", + "slot": 20, + "yes": [ + [ + "164487045052497920", + { + "userKey": "flash", + "displayName": "flash", + "characterName": "»Flash«", + "characterClass": "WI", + "characterLevel": 79, + "characterNation": "Procyon", + "discordId": "164487045052497920", + "votedAt": "03:53", + "previousNoAt": "03:53", + "publicMessage": "Flash? Flash? Flash!!" + } + ] + ], + "no": [], + "locked": false, + "confirmed": null + } +] \ No newline at end of file diff --git a/data/wrank.json b/data/wrank.json index e03ed18..40f9992 100644 --- a/data/wrank.json +++ b/data/wrank.json @@ -2,16 +2,76 @@ "2026-W23": { "weekKey": "2026-W23", "entries": { - "capella": [], + "capella": [ + { + "userKey": "zephyr", + "characterName": "XefronYokuda", + "class": "FA", + "nation": "Capella", + "weeklyPoints": 1415, + "tgCount": 2, + "currentRank": 4, + "previousRank": 3 + }, + { + "userKey": "dey", + "characterName": "«Deystroyer»", + "class": "BL", + "nation": "Capella", + "weeklyPoints": 3640, + "tgCount": 2, + "currentRank": 2, + "previousRank": 1 + }, + { + "userKey": "keira", + "characterName": "«Keira»", + "class": "WI", + "nation": "Capella", + "weeklyPoints": 4000, + "tgCount": 1, + "currentRank": 1, + "previousRank": 2 + }, + { + "userKey": "sean", + "characterName": "»No.1«", + "class": "FB", + "nation": "Capella", + "weeklyPoints": 1666, + "tgCount": 1, + "currentRank": 3 + } + ], "procyon": [ { - "usermapKey": "flash", + "userKey": "flash", "characterName": "»Flash«", "class": "WI", "nation": "Procyon", - "weeklyPoints": 1861.1111111111113, - "tgCount": 3, + "weeklyPoints": 5179, + "tgCount": 7, "currentRank": 1, + "previousRank": 2 + }, + { + "userKey": "invicjusz", + "characterName": "ElementalEnchant", + "class": "FB", + "nation": "Procyon", + "weeklyPoints": 2503, + "tgCount": 2, + "currentRank": 3, + "previousRank": 3 + }, + { + "userKey": "ayana", + "characterName": "«MonkeyHunter»", + "class": "DM", + "nation": "Procyon", + "weeklyPoints": 4741, + "tgCount": 2, + "currentRank": 2, "previousRank": 1 } ] @@ -19,7 +79,28 @@ "scoreIndex": { "flash": [ "2026-06-01-20", - "2026-06-01-22", + "2026-06-02-20" + ], + "invicjusz": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "ayana": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "zephyr": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "dey": [ + "2026-06-01-20", + "2026-06-02-20" + ], + "keira": [ + "2026-06-02-20" + ], + "sean": [ "2026-06-02-20" ] }, diff --git a/emoji-uploads/luminous_bringer.png b/emoji-uploads/luminous_bringer.png new file mode 100644 index 0000000000000000000000000000000000000000..94712577a08d03cccae496a3a7b1cc68c7fa70f4 GIT binary patch literal 22299 zcmX6^cQjnx*S=FQdJjPuy$8{w4Wicz;G>DSO}jJkNP$sINgz!bAc90J)atBO?F+-QI!#7~!pX;#2wj z))0GZTKED0DeeC*5Rj9{c>56kRMSWg07AF{02vJc7dN-|HvssH0>B?T0C<=U01RGf zwgc(_U|Ov8NZBN4aW~u|nd`g%GDB+Wx2t6Z1?IT^HScsy?Kh)pzXaZM5Ew-{?r6ob zV=_Vg5hsP%=rtnd*k@LR?jS;Uw?^Inex>|MVe#Z}w4&kniQVu%?W#C?yZ?UlCAPmy z%>U1<>~_JI*UTB;(m~J<)eq&we+??ET#VbuAHBNs_o-P@RA+#bmQ9CmNglmy-Ac#V z%tYA=F(53<>@#NB(wYY=0G$J1b3fO?QOSHuK8 zU;xMrNkrC(67j8q2D!>YU1H|{H4-8vsYI36EDLXJ_@k8Qkws5I2ZY!Jz!T&h8-7E2 zhqz{{^aZ1 zpkwzUmRem;}xQ_$?|A&iy;I{zqR9!g$CmWU$B!GJLk2s+=Z{HHK9 z9E8^aZYVI~@e@e58P}mx z6@a4!hJHDDh9}xf4;foU~=HEsx(1cgD%}#JyZ$v2Gm;*YJ2+XZ#ylf zg0VDPCF!B=8@F%hNOGtf5cG{S%pQp#mK7;7X;vbNk^?#;kZ$2R&?xdR)Nv$o`ERCM ztj^jA(UL%IRnHn^wHPtXChC}Txeff3!n7=rtloQVt@}ODSG-7c=Vp=4y!h!J^ocbb zn3e^MI1G6!&&YzT>k|sK#Gw_3Eq$hKDFu=zd9nW+{FVmNdJ_Z}i7*PaL9~GI1}OYl zk_v~7(KY8e(|B$Ol$GX59C)4wQ^N$6*F|WPO)p^9P`%;c)fDAx$*UFBi}}}fV}SY( z*d8k=CJkuLq^_hV@`rmnVB8+k-o-qcyfkG@;i1~&zPzdap74#;{HFtEK-d&(@0|0& z1RtJe2g=W6y)1%@$KAV{AHDnA{Vb(u#V@qcGBxIY2)>Y`(ato}q64#?NKR&!2v zc}%piAZYxBLg_1%kDcLRUG-#7^{GRPPJ#zFnodEn!(Wj_@T_z15Hm;>#`m$ul&4mW z$Y(dBJ8B|^tpf)AbXIbvAlSlCL(R}(aHP?Qfw*L>SZ(?my_CO0<@~p=-a@Bg3IUHJ z78OGjA#G$#;Q2T-{m+DisQ!Z6?}w4v$i#$95wQ!q*BJ8uR;C+-^uG*nt)?+NhUf;| z1)7^}1!+VxPuW65LJ&pt&;Z9X2gzSi3<}MCRAfb0R)X229^i~(d5qMICA|U?$?#oM z2(A&-s_TsJjw)=DGi2+%&+^>dwg))?aM6C0#BjJ>_+Pz@A~%ptdqmw>w+;mTo{f+!!MeG;UtYN? z@~jbxsKHs;1HIWi%JvI4X#wAU?SBH0r1(ePCsUDUKdn61*j(8hUUjvND!S)We9kxm zne$aUehOaBQnB$^D_8S^UJ~>RF{TrnyA_o$W;S%335Du5inD7y(2M`9@LCwR9B%&b zl#69ledcsp(H+`*&_rn#1uDBUXt|Kq#p~bq3G0Wtm|(GH-rS~R{sIE4^~u7{eF%I2 z^yn6eo{TJmlNcr%IYfU7yJEW>i{kUZ?gND?Z@z0&zE?ETTsqq~O_R_z3{X~-wo9);9a>xhB z-37rm`98l{)g;aYsqx@Lc61Pp9?;@!xLDhSt-X(S@QXG zT#6vzW5XYvuY&-Hzf||@gRlRV76VJRR|e@Ks;9cH#=zi2{}%A^$-Z>^JUp9pTG1-R z!?z4v5o?82E0l>4A(#-ih2nv-bp4t-`b%3;l8;vKNgnjDwkW?i2>CC6kA$@6<3a6p z#aqI(o0ypZt>4a9W9xxa<_GUJKtgqt3)j2NnWm-#7H_>}UToX;^Yuubq$%cEPL+I8 zF4|)cv*fWzPYsudk0c<3moa`e?>pcx(B6|9CYvlsE%Ph8&ZNPmW&2qW)|kFUul#YU zNc{ARvwp|z)k#<=A0?7hQDJ+sVp*Hn!_L9`DqKgAWrb^PI zcGeQUMS$wFE1F(zI(>Wk^Y`K#igY_O02BZ3)Z@ha&mhih<=q}C^D?}Fz$X?5%=$oZ}V>*b2VOL7p@0cun7QE0*a_mf-`nnOl z;*iZ8|DlSk5TM^^f|br2IJh~Fr~yh)EaP`KZet-P?@5?`6@Y416r?8HB?VUvONEi_ zbpTt_q=8I%E$Niv|O}KI1T$bBr+SXKN_b2x0f1v+#h$w7l$2q%eRAu&!I1 zO~XMJnabVqZ?HN~XDUnHo&qQ^X6c1Ufm(JJCV3lp0$?41l@|?!{ElrB=*aRCpukni zfK%sHrUzWN^g<8tO6se>7dUv_0^sl8?}h^6gMiUyoz0l}i67ZJiLQ#Y(Rj~|XTbD# zYn={&Du`ZtWX*}Vhv#H*!FTKg$A@g~XKnY9Di~T1jZEH)=85?Dxq!>UC8eOYnPvdN zdevhX2GGWUaHQzN+2wUp;A;IZ+XuaeoTo~G*s>3u^2o{7lv1xpMbs>wV=u{yOc{}b z+L!3d*gFx4k3AXd)3gvuXU4Ggx#Y8j1ht?2H#VSu@$wz}imovrXJ7ijL{O01chdDW z3wF$k=}yES8CInmX#giSh4(8NWAT|YLk)!XhbjXNHNz6(7zlV)Ogfel(ZR}S+2g-k zUW|^6cEOWxZnz_JL4_c1IpkA?`P!$D8yF_iq(m|-=RslZE;Zh|jzBYp(D;SrZ}x7% zlD%Pq`b}rt`yR^GCjCn@ET`hBQ!LvwgmJlnZncVdfisb9{!Ido~hA0ygr1d1jwF|+-h9dY@a_F!S80dP)ph}V%NSoE)W zup}`N(C_RH2|t-B8CJG@(oL#G-jc+?&I^(xb}9^F|vE~j8z*b4gTo{%!|7JXGp8mB-M%IryXveFtT8%c@f8p?(9Wd(}Z36p4}*d zS5JVPLI_3yU^Rs*ZmQ~_w%!AXspBrw^#M<1eJ8tzOVKz{WW8PJuVjfya6`?ak+ltX z@3$~;xRr~=ylQ&UdklryKUtf;(^!Fmc%}MCO~GKjj1$XGTsci%Y$|V@IlFyKCtdI} z0EN>+9Iss~``?#E9Sy9Wy0&va( z$jHe&pLN=jfNf)ztt%z-!pJuS+Ds4ge5sIqfRRUKXChz6K?oJbfO1v03$}Wd(jQuv zzL}xB5L^@RiWnEW}78>r-17AQ)4C0YDQ*AF4 zA0aU7L6GV19+{NL?jZ++CFLWp$jg1EarRkFAyOdu^Mp#@D+?J!k$q%EqM0ya*7=4m zq%V2n{Ko{kC-mCxNk#PAvt`+e%mZV^?gzrXi>wEYDvu~RH;#LF)}v+UY*!Mfa=*Pd z{9^uA>`(-_j~&XbBEkbFuTFJ^zRKXNk~8j_ z`A|6|L@zLp2rCP)z0`#}uG_qVhehH(>1DIWaEw|HT0y$ZicO>^c^$(@?gZUP8q<7fW0-$=B|YCrV6WU3kPzUy%ZCIPcAs)sM> z%MXCL*6K6H#-+mwV>4Jq`Lb0H4ug7lOYe~Z^PI+QQG5PSHG|^qzKJsx_FA2q+E;V3 zBc<2EqUVMZ*MrlzWDV@5b#(IueOyF51xMTUL-h}J~pUM)EzZFS{xuH4zFV~QRd^+Qe;2I>w9Oy}<3He>kW%2}zmqyxh ztK=NZupDgnQ@6McedpR?vW^~94wTg6b?Fl5rK=6|>&`|HeA|z_tr<^oP;gLh1*c zO|6pS%|lYXj5r?Irn0TKKZ*IokaXYb=tL`76d}Zbk;?@AbbdU0HYF8HY}j5=?b0Jr zP}x;Indk!I%brqnr_>4|AcM>v2e`BX4jHdj7p{2_0|AIW~=C!jC9(2MgG zGOVSeTA-TSdpecn7X)29;NUy6zrqo_@Wc=jwx});L86OGfwX+vKGyUn;-FHMi624X z)WyxMET|EG<}F$P|Hk>+(hjKW1+gCS|Cb|0JO$na5N@NiL zbzqk1cJpxD&WfU93AP8iI?$%!w6KhRzAwsI$5TTd%lz>H3~<)(0f&%vp}kBP7$Wek zBI+hy8bJ+l)Rqsgwf(ld$;+m$JQ+VfI;7QA$dbyFa ztF0qS4!ixy&KZi-r?PQf8OwiS8=ur3fF}Cc!f-{VQaYt=5Bywv{2;v|xixgUzgP0H zgc6%iDpTE{m9YVwvbDkWM27>nK*&J4Hb4aXSbcU;jzk-C+9AjwI&GU>E8+@-2do;L`7wYaSJhTvpWOeUtR!z z&~_DhXaWfEbv$1{s($`dgjOGsa#j3vR=W>cOFV%qn@A_=h5E>|s7u&6IERJY6h8Wb zWmY}6RsT|W?y7QraXpdfjQ9(ndPh;k$H0zT^vQ1XGzgp{Y{5ZS9hMS&;QIh22g0!+ zy_%@9n_d2*(IL#i{b?K6n6}XZw6}ACs5@nJ3X0=!sfISDCK#5== zETsAkw9hK=1=AHd`U#%+$SHW~j^r4muYc;-&pH~`nXNeka>CWOPOTyjMJesBCKL9X z+7Euwy0s^2w#+5At%;DnyG!qRSq(yPqx)+ov*s8OBg8>Ov3uOV=_Br+OOr{#WueCW zTbZ2RW_keszU}SIogv&nFp@3V`B-JTcm`&m`z}Ef^Vc`bP-mW>vklj>2p}Z#Ne)Ub{z=b&`WSUNABCN9`*-p&3$d!}=bfKO7y0@;;ExR5L=#N7zMiA3 zSCRUA?`$u?b;Upk!%F53w#Z~yoOclN&u;aMJNG;z(QJ<7agKDGwfy*CjU^b!u`Tiy z_)mGeG-WF)-`yobc=!ApA%_ia3zwbIb!-Ku3?y2HU<@TkaLPbF3tI^C=!O;Cm{ zk|h7eG|zp(>MZ{+0roiDNz{E2EA()H=b5ods?lBu}tZ2hoH6@~GQ$6AFS)e%ecf;#W9XNE(iw_gYqw5su7`*$Rof2~$sKS}}{ZK~xuHNzd0ZsQ(sXxzzp*lYSq&O;vsA zc@KdpP}#}VpmpPlE7UkY)9CFXU-zrBh*^hzEMc60lZk$sgFr1&_w)gIx@+#QQMhs# z_Dl@4tc%3FTe~O}oV69ifu{SNOz+c9l7fw%0i*m2+j7Et1`|pI*sAkxM-}yn0lf$I z%vA9ZV_JMD&hYU(! zIk|BrJv^YI?p!#E(SfDLD-DbXbrAs5HLa(%wD`f|T|&8d4aHP~CtPs_nX{#igTY7Z zEdi63tt=tk5X=O}=LO^C)gcg$KH&KY=#=sA{x&(_m0)?ZYPmeNzPW?G@}#Ra;eB3J zkEbhkJq{q638lc;Le43u=iY*WOpF)FsB*%Yn_~}C|1bwmRu9;x}wW{q_{e9SU*`E?9*O-Yni z#eGe$Qm8|CE-gG?O>{$|8A|6Ox%N?{3vbJvP)M#5)AnO+l1P2R20q$V@&{Zzs~*{@ ze^|cO?6j-o-1qp${pI?UyTiuq^$U#?-TD^1!;nsDjuN>-*kBXP?G~Lx*Zry^T^PBS z&5O2$pgtA-+YZ$__mu-N*n{u!&E6Ke0+BOYivf0;R1Ihwl48wqWxS}s zT9d5m+H3XP_4$0YGTcz3WQbe1Py|b_>G&Hmj;@4OV>xff9`7PxGRSRmGr<@Y^z9*;jH@Lpi(ExQ4u#+h-XrV&IL;?Drw8#%q@*?vHI{8UP1U_Ggs2BI3sixX! z4$QuaWFe`^;2q}|?C#tT_5!Mo*nhc)Q~mfcnng&us^4q*Vl~(uuONc!AO=*Ch`)3p z+Q-{bgROb!w5KW4H=&|728w%aECknK^7X2$Qr}ZrbT3qbs`ez^5BhwWidCnmUyCFA zeaO05G0AoYN`#-z2C<~hqebc=gSH*>=*U)(9W~Wz_ILTQ`5kcFJV#3;Nij%0KGU-FU0I^j zH)m&K6sz)=Ik}igmXncPhYf5YPNwZWNRMbIgRK6Z4BIu&GQAd3NxH83 zLPC)YgA$b(V>tr!b3P0fNrE|39;gYcm%ChNFyJ49k=<~(Bt1XWtKpkTpU=5g--=6D zYhvx%`$yzgWYdQ(H8Zud?kc_k?<}*YHY)CdP_ioiO9>0r(Tr|&6zph+p6C-%lVRn` zv2`~_V`us`uhSfCJrfY5jFhHdqkUQR$e-K`>7*p zXA=1hEXc(Z4hk^yg5rI5?S&EMQVVZ8w4@wO;Z!m_>$zJvZ;L{SV}>hJ&yr4Gk1Jrv z1%OCIozq`-w+%-7t3 zi46fnzr;iCzc+UINlA7C5P|FtUv<@eSUG)7Y`FV?(Ht{*+S#o#6-2Oc5$(6$>7n$LMI1F^)dtJvG*a*VJs*u_7+={c!=5TYL&x)=V56k=$EkxZbp%O2W4BIk-u!n{ z#eqA)DXJXl>r=_l>7j;xFYOnF3zrlk3D~Cinf;NXJLkh#~t93DhWa z3TEUREWLChMd+>~eof*(b>Zhr=5h85v`%HH^xRlV%n0Gh1E8w0iVl=bdj~EHcRO8D zO6n;bZu_V~EkM+hVF#k^Hz?ZU*TU$442R0jrXSrOhJN3Qt}IGkQHi3Vzk7@K8_>t< zIVpTLT7a2470$r=ANkxfE3LrU??8AZfd#*FK#0iBt=8kMXd)jhsM`u$5I5Yj$+Q=K zhh1dtI~0<_i29fn)J)vKsryBUjg_cukYiCmx!yR^0QKn4;16zPgs-WEn7f;V^v%Wc z>)a;NV};lGw9n;wd)|wJCT8ExHfRZw!FH^ZKHW_LBNwSsG#bC@uoKbBL>bopi#iH3 zzA7=DL)BX~m0?9V**w5>WhjMuTvrqen{WtDc$1g!4njQn+E_-SgJ;J`DEB6Uq`1yZ zKW;@e9&0OYj+6|&FX_=L=FIuVfAg;#0|3up6Y-dh5=C7f%K4mIB&qY9yuE+r`&mY~gIh$&pFxey@LZB1-N&?kiin z;n5491B#M4L-{T=H@hmev@?EPHxK%q5kN!+ToPC6#@4k=i*qx)Y?cMWXr%St?<>wz zF!3jQW2D)~ z5H0NKilIc$GcdL{orc`tG3cFC4ezrNqWWemKJtE@n!=S zcuL3+xpdD!{cfd$U#O3+*iWcuGAZq5yn%OpUaWfu_8_K7pGXMHsu2@)E}I6xn1jlH zIl9jtP2f$4S-Zw4}vU zaqV@p<2rkRUrA(rD;Xy?v}gj!mZc53VQi;%$hNmIq118mIfmwGHO7Z^HdO}I--j$dNT4N z_0Ll?m4o?;F~Zpe%o~}6SVgJEt^!*yQm;%Lq_Ef{<=%4}qfpEATD~X2VUlUbnGaek zsN3(8lLJHMnkhLTV|()8g<86jdzqh%N%X%JZ}fAX3)>5%RedA}%AUdSDI{x7Umqcl z2_;S3=uT#w1t?d%_X?S~LKtEVG(W`}l1m!{n31#AqnoTLyv;3xkrvWH`tF*kf}vBQ z;2Y1?wS~Js2kRdhMm_mu{Y1!b!VTA9OAzu-i1h&D5xb;t)m`IMnr0GK$_$?6cOQ1; zH#1GGctk$*nmZ3G*3s}_Ot3^7*?)YA+2wL`Dq0DhWR9MBKRbR!l7=+_Mqc=$0RUoUM>R@X7i1sg z-*i8%evtguP4@7PP6LUTklfIpf#)qvTLl)$cN^?s5v_!+&l+y(IZys^**0cXd?bpkKqBiTiTyVa`m26EZv9q5Ac`BQb@1fS4GLFvfw#{KYuW3t!=rs5BLCm@Cl0eh%TI z${R!6*rF98_1z8}6U+V>W7k`*TeKgvca!^)9Ywole_H)ls60Mb_B~j9S4dp3uvbDZ zdQv*2ReT2BBhlRBcxCYV%g}?PDHYGh%PI3MaRq&W#=|!!m{&RCmP3 z_~06vdn6iq(;X}YB7i;o!|j#H<(p$MupM~;iv!`(g8(J6bj%U0X6YiyAh>NVj~YlZ zPtANA$oZg(y>Mdfe6)~fUYZ^)uXZDhoncBQ<7Zox<^5YXrt@Rs(pkD}h$FW zY+kQ*uqUrCooO61BI?zCb@$=k$!)#f;*dTTDsx^IYLHmOR6^M`Kv zGIQiWSQRLWeXRNEg3Yybj6AQ;c%M^U8fVetip;m0(WDV-&5`!78w#K@8|%fm)})3& zEb7|0&M+=}4wDKxX6w-tiVjn`?H`@jxip0n8O}`}cgNjwz2us3%=QBb;yZ@S;Cj#+ zIhtn(gRYqttd(y?-k9^PC zRPjodzLhe_p!t=HTAot|7_f8^WxzuysQ6xS2){`Y((A5%+FYi!TW;Z}*x|%!sfeV2 z+K{d~Q7sAuHuIY`ktJI%4d#ZC6~Fp?+y^$|%8ZZZW87f0@*h@?X6AfRD(1t{JW~I2 zy~46zmM_u8jb*V$s}>SIv57j^MwmR$U08-bss?sD|D?J2k=V~EXQ_AR9=%|<+2#h6 zK-kEVQrg9)#u0~G`jH$xm|fAxq@e*afSV*-)2T!!UJ)ewfr1O}-TavByYcy1n@Bz0 zoEH+V6EZ{PN(I4)b9_iU%AX;65aSu9~K2m(R zDw{Y7Z0`N{AAX`zo|zaZ8<%YbpI3Y_W|G@@@t|M`6voX_=t!vfiA)uOk?eJ!y4;W^ zR(2t;OXgO%Rz)H;1slb!8eT-92(t6Thbw^!1aWE%8cow5JedjIA}*{12Gq=SNVWI&-Whd5;MPmzd1{pRT|DPl~S69oWF_djbtUbgnb zk~`Ai6vzQ>-~i(NgSESt7Fe!?;U0mI!+%p;AC*f+>iOp}l0`-FUw#MYr1(G36Ur4!)hbpUhFaL8FZ;@|o90P$u#u#4_ z#Es2q6RJK^nyL|R|(vk(2!e?IB@lvwt=0NeAuowDo>HRMz_U z!${$5GKqWN;$!SP&GMoFBtq=tBtz0)4F__GWO?f!#YvV_K~cS->=Ju&cqM5?V|igWW{@{ zcC$LUBq-q(VRxJ!0nq$lpN&%@Wa;bTjhqQ<*{R-5aB>u89biS)cTgzAJqMT-q?$93 zza+^`2k5Xy;S)^G$WM{0e+&BOozta+Ke5?=zOtdiuWb7ml1PVuFg=Mk_Vp|V;5Ly( z=jHwOB@VKD>j2<)FIA`2Ybl8miQQ-9umG#8ik(g;@@B1B)uCTr7|%w%GlSOp_rPsl zXN&JDV~HeUpKh{}~g_jeE-N+2W2SJ~iiXx_WVu6}wsXzP%ZCj~9GiCC;CIL&2V zB*IkOH+W6%r8kx#!F3SH*RsPjb4V07)8zNFzD2!RBG8!@F>`XV5zbgZgzcenb=vRy zBLPj_Sid^7tVb{kn%RfaDu>u4y;;)-loAXb10@t+r+4_NT30O9P=90P*dp=z_BmH( z`jw=g<)5z*+x4|J{duyz=ZWS`mqTt%m*>~5oUlk&mQDh9%MSM%1{^D2At-^tvM z)1caW;zfVuA=qGarkzsEWU=dG%P8wXQ=Nv2qKV8rw_7@#Mv;09rIr(QbZ2P^Y$PJM zC#=LEEfh(3U~B+HoDt z55;?aapV1wcdgbOTnvfx`)bLd{3TA;vO9Xpys=@s{p0pEX|^#r0*QT=&c=p{|K%hX z{2&Cvo-?i()~)hc418Rrl7bgvRh-K7V5?+%L^3;6c$12fq;l`joPfDCAvgo949!OD z)y=*8*g>KFjI!>!!%4|(H$p$FC3B4hD&2VH(d(45ZRq&`9y9=L;CXWtw8IkWB&rDs zMnYDQW@7FW7uVEF(#5UWIWkgs^iQIEZDUdO|Dkhh^Bjz{=W@GPGZ%$Z&gLejqc(83 zA!+2dcVM@_4OY?0ZT)Hb*~@Rt0RrH65yMot#X$3pKoVhT66ZJ|qwv@L9eeT&wV_Sy z&o>g1$c%cAx&~mm$p!)v0-yy#JrC>;Di~9w!ZM>V#AHgHn`y;|Rdcm>NzI>2EKnV- z-6elVUbbXU;tlZK64Tw@IaIjE%oK}^0YA{9Z||STp1j~#jHQiR^*u-IN&1eh`E?`nRkbSL{iMSIp&l~MiJyT*5;@vjz2(#f z;-?`L{bzYf)K!0Vzi%w1&^#;&@e~ybx#13&8+Lv8uFnFWA$NCg zY7XB{&>ft=zKG)joY5`+l86d1<45kH4Zs)|mh5Tx01>d3AiL;Y$0Zv`0$FO-b17!& zc`>}deNyPgGAyeZ)1x{p2!UXx7}>hjr7lOQaIz;B|Ma4+4XK>T*$}xeH+7;Y2Yz@F zD>68^$X}U&9#LerK8Mv$B%H9KJM@}yXJ>OuKG%2hrBK~>P5YNGvlw|iqUIM>_4MYOdR#>G9TQjXc$7a z5*OKIqm=$_mJEC2N(VLiT$OI(QibWXk~KN}5g zMWCa!Ig5HXdtd>Ce00HF@|fpU8TqTztOin3%ifL29sS&e^R&}KE_CGJ?!Ip-6UBEX z4<(h7FHK;`4muKYiJTn7I%t0p|}vtpU);^OoBG{7Xu3s)^G>qlS6 zm}vkC4bwx1C%4M5kjrXv@|Jdn|9ZEBK~>NBs@~X)y%(nWYi_^&GFZ_wqW_$kAhwCI z+ia6ZMg6zX8h1q!=GN7+I|qwjuuqQG9HAUKl?yzj-%zwATCTBnv^$TjkT#uBpY^1` zOT~12n|`Ugk$kWCO|AmBjTyW5*Sv9~PIqbC`c_~8&$VY}pDi&H0*N2y_H1HBR_w6ogvZWf*vz^4xD8n8qeY~DfbziQ&~S+a#9d&0)`KM^q8

2IS}wJ2zP{hbuV| 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 QSO5S307*qoM6N<$g1S}nV*mgE literal 0 HcmV?d00001 diff --git a/emoji-uploads/storm_bringer.png b/emoji-uploads/storm_bringer.png new file mode 100644 index 0000000000000000000000000000000000000000..335cbfefeb4b131470f806b16c458d688c0055c1 GIT binary patch literal 11300 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 XHa7W%XN)UeF#v(5tDnm{r-UW|E10|v literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_1_gold.png b/emoji-uploads/wrank_1_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..ce060d4287bebd8e3da65b811670c0f19e139442 GIT binary patch literal 945 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G$6%U;1OBOz`!jG!i)^F=12eq z6_P!Id>I(3R2di=ni&{={s+=885l|p7#Ln9FfdrnU|pA>)8je&vL+|$J|q~g|_ zI|qFaI|w*jjC5fvVNi`IVvMv}z+%Hoe$x!cvJ0CBl9%0_iVKT z!;1u~2OGBwZZvm_X4tk;iVB;U4IgG$~XJG)koWTK<1VBt` zF{oz}+(pD1*&wXF)-?D^McpP3=-pXw@~)<+o(K;Y@>=d#Wzp$P!rbh}Of literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_2.png b/emoji-uploads/wrank_2.png new file mode 100644 index 0000000000000000000000000000000000000000..0c7a4138b174cf140bf40642157ffe6b389e11b9 GIT binary patch literal 1981 zcmb_dX;hPE7QSDK5SEls2cn_|#H9*Ta2o`LL~Dy_W?&Fyk&)maOGOS0Aj>3#PiX}Y zVgw7QEXlahbdZ7|FklmC5C+56L4g*CM0QyM1PCF^Hy@tPocS|n=11Ri-h1zRm*=_n zyg%->-+OwHOg5PS0Euzp_(=c+HxVEj;_45vUx(q!IOaq^9DwQiwTr;r!VS37CgjA) z-vRu22LPG|pvG`L32?~?;B7E~>umr^bPntJrvOGDGmalUmGsl}VB;kp4{EbKzi|3~ zyv?4BufZF+cO}Cl!_R@l^gU$hF9e^8UPu7DdT@2q17qT#(F-*F^Dr&;KY3LA;!)gNsq+1 zLHf4YtJrc>j%}-mb6{q&yM#MrAP7|M8=~cQDtU$&vs5(HIsF+ifx&g;8!;VWKopvz z8CBjJ$n35Dkown1@7fa!tE`S~WcYLw9!ejLW7b4wj|8|J#sgEosy5>k8zJHuSxWB6 zkybuKwmtdBzPZ;`B=wHg`tA8yuYC)V1BVyQ#W7y8RjbfD=L<%X)z={=Z7JJKH-2oc zucyHZH7}7mN+)&d?Dw%H@-t@a+dxNf3(V8a*FRwuUs_(G*|V?#-W6DM)iS4UI`lulPvTAuYN2r! zJ715@=@Dw+SWI?@)>BI*ls2uR_Ra#H8Johq z{%X~)EkWP^QEPN)U!vXC#6Wk?*rb?Q#VD09=|$3o;%kRY$>{9tydun1A*d8`c9t*L*@9bN zZueV{JF;%pK|80+USnc|g>`Jwd0o&W-PoOQ^>5K!AT44L|tb;5?%_2ywvgc93D!?Y#~7!-=FcX&DIcMS?e3d1C8O?jQw_ z27RhSAaf7=R#N)C)zIU2gs0`V4@=iv5)(}-4#L6P}bS+8F_%bBO z-xm?2NUviPVmSqNz8N=Z(Y8M^fti9`0X$Ay<3<$cELqkQP&T&Lt)_}V*L2u#jFFib?V&0%Z{+mD5dL!|?p12^$zKl7PUGp-kA245-E&4N)-Yr$ zacTT?`21Bqf!?jrD;ItgilIv`O7)i4run2If~XQpm2Q_OUYGFrv-67~>-!g$3p@pMm!=I9D>DRJ=o#Ce@iE z6aPqlE~j0Sa@L`b(o@e)_7x=6dExwZ3v=u>BWWelZ7}iN%38B;j!_K+%*+=O%%*sX z0(ZCQ&vY(Xa}hzeocd-D4G{!XZVu{YMXperqUSBv;?kFDlDi^3HT(`a};t{;^UymA!IJlUru()m3?)l{9Q87)M4V5nBYn(~Wh zQ>cZ=1}+)xaWJD*gYKJ(*VNb~O!Rp46Ik8XWWQRz&Pi(nx~N^U?SEUCZ*37$&HwxI w`1e8)7$ugKm6Z|>z4wbQ8v6ggvP2bPL#V@0(s7ReUBD0H6VKyi$AYi?6B__iY5)KL literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_2_gold.png b/emoji-uploads/wrank_2_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..5a516054c82e28102d1f09132308c89e6eb4c4c8 GIT binary patch literal 2038 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 MJA;!hOXGd`Hxq07wg3PC literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_4_gold.png b/emoji-uploads/wrank_4_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..e8654cb70dd4d647a28b9be09e4c2b13a7cf51c3 GIT binary patch literal 1407 zcmc&!ZA?>V6h8M_Mg_5D=!Sf(I6zlfq--Nh`~U~YSL_@^nGtBSZk9*_%2&&}mb80n+B&AqNR+baIzU<+7(PlrAkr43w|mtO=0wlKpJYU|-xw#9#&o4WDXTETFc64xe`2IDz2buyV zH%IZw=OvJAuVGn855tuG0#nEtOY4)c&dM2UCt|80z7`) zm)~wq>WhcQEz9Re;($WjP=!h`?)?XxhiQl2DN#^gd%P&cOWGp6WNeMwOO}s*2CGWgqlrc;ME2xb+SIxjzHSqAmFHMGJgpOB z@Awfr@{7mC+4Da{uZ5*OJ!Xxu!83!>j=d;-+p#E?OZP&Fsd>0mr7;$h(eRpeeYcWK zyC!f4Ls$}Dq|@{d+5Tl05m37B)99 zuqnPPj$v^(3YLS3GSyFQYO1!$n5VRg8j&mdPkj?NIW81Ax9fUo*wl}O+m>}Pp4azL z6k@?#6*BH?X177e`CdKkkLy30$rRCi+u1k1U{U+3Oh~NqWIV1v8ar~VqrxSVuEk1B zX)^|Krw_Fv3kG~WtZyWvll5DJ33h|$l!YeQR5(6ZOi_g2lMk1=f(dCu9mln>4S8Sn zZnnEkZA0x-Jl92oI8%2FdC#^)lIWhjvr1%+7h%QOJkbd!Uo|d+s#Wae&%lH<2$}XG+TJei>h1R3AMU1`BIT)00||Bl08CKyiYTfWG*NrNsCB# zwI{J`J_CB+sy!k2V*`hx_|in8dq5MTFUF(vZ=RIcI{PcC+hle*JlPWJgrW5On zyye_1gAi>LTH|6|-EN}Y{{1`i%o@o}-JPA?4-=f`p#-7dzx;&w7&X=VgiwGEsX%y` zPHeeDHu{ojnps`&l}A+Kl#nX?Z~C9QCxE|1o;j+9q>0DajR?DUQ|xF7!s#bf#i literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_5.png b/emoji-uploads/wrank_5.png new file mode 100644 index 0000000000000000000000000000000000000000..c5504a52c4dad10c9e3d42e36f800868395410f4 GIT binary patch literal 1850 zcmb_ddoety zc`hrHnQE6MOyLwUXg#O$evDz5o%z4rvu97|d%oZMIp2G~_k8aCe((L>a(A_tmEI=} z0J4q_r_TW(B8UK1QY3uBD*{AfN2mia0zgJVJQ2t%P!?@!TyZ$(0uXx;fR+rv7m6eo zAj$~fBN@Of7eI+ZC;eduAjxt(ZGArG{b$aorvee$7mMSlC)D)MkGUhYsQNwFJ%`;a zZ)RB5dVy%1e9XFl3`!sOVp(=g-r?N4Ec8pgKg^z1bzP{CX!dK3py2oJ=L`>{EBXvm z0S)qSQq1>jf&b=7a=cb^;C2&bbcE;aZ8x3v%q@I-V>L)i6H1RALt6!hVD*uICxw9S zWGsx=I)tAejQ8EBm4PZx9}Nkoe~#w=MQFA-Ab-2pmG3}zq?hvqEoFi&K@n3(_d9Dz zs)|nsBO@1jzPfJ~+n;%t$hAbzRz)S(mIARN#ErL>&5#bbvZ#uL})c{yM%{Y^$6h;d?GKx;&_}y?XNc28|b;w`PH+2xc%P zZKdnx9qK{Fyj{6`@7A}*9m@^XgFFil6UVs1q?EK22reDc+L#U}sE_etR8mUudNKDC zM*AbO{n$3P(AZsiSMpPh^Q(+FX={(xp5`>?M20j=Xy75&XX)7V`NOBewn`L=rI8~Q zXQ5iCZ+A0-xk zzatH+l3#k0V~8_ObamAMFNZdR9Wb=mK+v<%g?@sBJRl{#g;w0D16o*68yH+z%T7HH z${F$YiAagB2Ojs%AMwv4CGjT3fiAp@zuES75r4)*9xIA<0_)^I%O5j)k90Yxdt zC$HXwUpn<#yLsm75Hs#iEbI6Y8P`T_vy>U;6`IL-py`2h3 zg(kYmDY-Io`B0uqeiUQbP;CoSV%Jw!)attV@%ifjOy*Mjv_hMeSwe8>A6U?QO>KYDxWm{fh)l&UN+&{ z!#Y>v_M4wow-!ZpNIb|lC|Xs5N5lMLB2NaQVihT?d*G49(hy&&Et59D?}icu83I#O zT~6Y4I!k!i_oD)>^K+R+ebtoImU)M-&!9c%HaKs#WpnzY(>G`1dHa} z=dT)qK+rW-XYO5-xn|J3xp%5s=~7nVcJGES%mkP{vn1p`h-Ysn%#E~+t_Zfe`h2gD zL&}w4gLI_pRr}Kb139<1+XYmit%X-J!8)*L!f7r+r+AfWEAIEon2IcK@~ literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_5_gold.png b/emoji-uploads/wrank_5_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..af5e9eac182d5d73cfb6fa5920425846f2470b89 GIT binary patch literal 1932 zcmb_ddr*_d7C&DMB*svnY71gZm`fp5gj@v6OA-Z@RHD2lB|Ms_7?ei>MtLZZPf@(i zy%Y@A0`hXS0@@1*7?eU2M5#29SSTQr1Z$9rKtttC0_kV}x_{hp`bT$X_nbX@_RMeg zyeb*zeyk=02791*~pwQm*z#U)mlka8#>gavT& zBLFlTV9|)tF@O^^fLA;KuR?(Lg%a+=-2jAlnf^P%(gr8glgT~dkn>}|x8eK9wqjCJ zhslF#5SpANQPu-CEjCfYEJ9I2Frs-U&}7gy#Nhy;&^~@ZqVwEp?YL(1bDOar@628d zj_yZ(m^p8U!Nh|FFy7$(TbRFQ3DuCau z0CeX)TX|9#vx{`9E1sT$RNU^g(@+kv^_?eI0}&{I0^r`x{535K$Tiz(_-wbMbV*Ff z$nP#In#Sen7cMJ?Q<9s@86vF+kCqiVRO=RvUAn2%+MRU*x^EL#XdvRzWozBk@FIuS zHyQXmR8J)mcwzl_f?syW9j{uIadE^?pC;ZqKJ1&mHeVlMzgRdDDf!B~%!XF7{AtKLN_Xw&vwNegWqj&ZNy^e0C(PBR)5f~t1uCA>H) z9(85yV!)JyfyaZT1%fc}7C`z!9_O}gon9L!KM>JmOAlQ>sPbYO_bII_J^j<3?`gKJ zF`XDx*P0GB+9da8t=!2psCSF|a|MH0hF;&Ud7n>fU2kzCIrxg}5S92uVQ(B-$l#7L z%dUI!WqC#SgZU>8pR+{$$nxF~m*XXy8cwEbRr-bK{RKWMfy=9E*IRkt&~QN!lqG|j z)gATx+glbsif??ILWn5LD108T&x)40J+)82#A&^Ka3ZH29qMrH43^Z)cWLLoCg2#V z1c}dHQBR(8EA1y7L)SRwL6?BX4MV@w8Q!G2#=oZH6}992O{ zO5Kn-n~GQ!E9|-T8(OI^J!NCOqA#b7CcLT+rY6R{SsCZHh4*Usp(rEPeeeSzPN4fp zt}=b?(>Wl_i#4BS$L3mwqgXxR8rHO5gT@)jR zyQ%T)sZ_d>nB%^xP``Jj~f`q6-x59bXl@>?mpr?SvJHzmJYs zpxt52ecdICv2JsZf*~cbU_`f&OUE%T+8CLJ`{qbRfMaqc7pZub)Di;12EJIiLhI>)!6 zsoFpW;_=MNslNQ*2`Qdx@s&~jhx1n!v1D53y7{hkw%=22xv|!a3;2quqPAq4Y`fOL z@EiB&ZHs-EselVgBRd>*?%Xc0)xURkQ?%x4z#By{%*+1M6V)It8IPZ@^e)r5^b8&1 z71g*5Sz=*|Miy!Q)!uHybxf62fA7%m_|mB$Lny1MA8#+jVHGmV!;<*7bdLY+KnM;0 d)-ujeEqg;WH$|PaaYPQ-VD8%IU$c{U<`35W<)Huo literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_down.png b/emoji-uploads/wrank_down.png new file mode 100644 index 0000000000000000000000000000000000000000..e74e77b7c495834c56fd7103c25695f609ee2e78 GIT binary patch literal 283 zcmeAS@N?(olHy`uVBq!ia0vp^+(699!3HFsq+HVlaSA*li-F?8Ak4@xYmNj^P$AhP z$d`ekN|k}3p_zf<=YJsml7XSrfPvvv0t1893*WR44$rjF6*2UngD%qX5jz; literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_down_1.png b/emoji-uploads/wrank_down_1.png new file mode 100644 index 0000000000000000000000000000000000000000..071343f2c8eb167e522caf96af618d07fdcc0511 GIT binary patch literal 956 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G$6%U;1OBOz`!jG!i)^F=12eq z6_P!Id>I(3R2di=ni&{={s+=885l|p7#Ln9FfdrnU|pA>)8je&vL$M0LET$7LPjNVa9LK^Sz(6eqF(yUK&Q`WqyGwRj)vBTc$@a>RUQ6g_ zy^+diVoYHC%h4GbzSbB=k~|W_t&iX!Qn7zYMu4{sJDU*+_oo@v|-w@ww*1ManD*vB%a`-rXO+{qRO*hHXGk z0G-YOgdqQbnbcwoppgeULob`mJk<26?1l288AsxiuZJvqz=J<2CCoqFcu%%92`C1Z0z4pa}>hke9qZdd|$5GoGHAKju5rj1|9~0Y5N(&)$3v8i%N{reO3Srm!ebAATr z5m{BiQhMjUrATa-uZ^<98@YpfN5dFm%A*)G{E3C9=u>PZgEjx6diz_-G{ttzo+x$a zyY5=xnx3n!(yfAY*AtvDf4M9*StZXL^2^;C_7%Zgl-6Uz{ZKvD`VTrbGQS+arjn&c zdE7Xw`BoP)jO_Vv=ka6u1ZI7ww=@Q|7;XT z8ZZi4UP?myQ!zBDN@$~KNUL(1+AmOy+=^u7J-hFC3l!nCG4eWW<($>DiYb3oYv&A6$M}md+p{SZv;C_pX@&>zq0V?mAI}v$CvdXy?*o-fuFh#r0)`1Z=Ca zkHQU@|4X4m{0=RSXB-!CJd?KfEdC`%{YrhVm(4zR6AeAvm&GWNF!(@`iHlmPXE8dlx#vCb83| z`n(|JX|18Q^-4OuvP|!#E?L^?{>FwMs4cNl*MHd*^STHKFDrdCT~->@pX%MQ*AsL- zRI(JsL5war>^$CH{WZ`GsRUzaF7d-;cm6+Pa`tt=&as_!xQ?lz)TDZ|Et7s4$$ z1m>L_E#vg(r%1@stsv*|-VP7EZF~yTA=*ZJBoY}55 zF}s1Adwt9ldQ(EDYOFORq}l2&7Q)G!eXCv45~bt-O&x{D4}z#}N-OX8E%ay`(zY*z9mGEhQC)V6af|&;M!0Ly2lrE-%BRH=Q9IGIfPSabTXJo< zT5blGXNp{eKjoe7<(|AqLiXARPF!%$nwPvl%xe+XdDv%e-MEM=T3Ix`dMUmu8AaH=&UnSL3%aolcjt=kZp;C5&Of)A@_fh9Q#f5kqES#N3bl&TD8W&P+quN4KS&RGA=&>m-BE zdAK)Ygl}i)L8SS&JyV}K!OIkg(=5Lm92PE8Ya1@D?*u~+vS)NercDSk_u%26yqv8O zSB`l@^W7}LWa9>@;{0x8F4mTwJ@wvzYYk_z&U&zLR-oFDs0(C#q7VS$e0p literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_down_3.png b/emoji-uploads/wrank_down_3.png new file mode 100644 index 0000000000000000000000000000000000000000..38984996b2125c47e14abc2dce2bb6edd09e250c GIT binary patch literal 2553 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 literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_down_5.png b/emoji-uploads/wrank_down_5.png new file mode 100644 index 0000000000000000000000000000000000000000..0a4d8f556fadd8897a2f4c91012a7a084605a98a GIT binary patch literal 1923 zcmb_dYfw{X5Z!x;7z0UBTM;4_9Pj}aC_y3&U=RT#^3Ya5G>X-Pwm=Yx5FRxbqKFDo zfeI1_u~idm3n;cI1VOYY@(2ZaN&pK~K_Ng2Aq0|M|MXX<_PwzH2Fu?W0tJ^>;z9Gn+jsi`Bw7Jr zLIAZ6_agwu?Eyx^09^6`jH9y;{^kv^cnNcx$LIW>w{pK@rk!ZoOW6k5%9GB*qrqWP zbnv76%G9S+^A6I+5(b4@dIx=BW3)GAlGJEs|KLbXzTsx;T{pWvvT@|a7K+bqoeJ<> zRdLYYv(2@KTaJwWCA7fF62J%;?^ypY;QcCL9sEdQLA$7!n^`Md`1KN*YbXR>cbAcC zSFcgmqW`8s07AHQw!`r4uU zmdq{nIVJOzyVM$WuvVL8d2VVtQ4vAFE}B?%yHDuOR}c30oeJA)Tb-xq97mA>-ki_8 zv%olmeTkX8u`_kR8LVH7-P=CpUaxDIeq2g~;|00IWaiXdI_*QeN|RynNd`#v3G4{Q zX`UdV6BOCKe`>GqAYjija*}sUJqsklbhCbOS<;=BSUr?+eJwjL?8tfo=90OM0j0j? zD2fr&F)&B*JntepYnuGq2%jEC|D<79vGKvwQr&PNx=K6N7cw^ z(qcZ#=?^dUTqhqtbZDwpg(Vcj{n{DZc3juZcI)KaVfJzDM|%bAZ>y!p&zUBU%< z#Edw9gR8tz-ykA`g~jqz>00OdFKE<875e+d{+I){dF-GG-}9Oqh4IY7&^?XtwaDVG zx|7OLt>`~|YG9b-bnkZ1s;ppS_98nwuiK4T?piCWocR%h7I&qCTlC!T_YcOo89^wA z#nB5U>n;bUg~FF5bSpw;6Pno8e(KN)=y`MFC2>A7eGxX}GZ5w3uv&HF)>R8UKIYJ5 zCUyK&iZV2tezBRTZ6U!}`A%AiVFvkUXdzeMNRg4=`US5s2VRX!(<)0xhr1{vo`am8 zd0a{Yd4g@&A$@ar9$^b8qso1CVoIOcdf+|zK80PdhzMr4n`4@(IMLmwDVPZcfiZBx z@fx0q{F|mWr#!GQg3(Pb6I1Tg-AGZNNlnkUBz%!Tz_-mClyRIwRt6zO)U?G1`daZH z`Q+Q02IXrz;5`ii0+uM&ojySsh<{`ayhqn?1_;hzQhAlGZHg>)`U!6_(==Zzu*VU7 zzO}|-iz!}gA&qHEkv3k}!w49cFRyX%(=966hmY+Zokg~(>>ku#y^u3RkH`gHQ&e^U zu7N#XbOpwh_@Dw>pC+*zk3>cYpY{8swoZZb|qJmoNsyj7~ zNfip;EG-&Xb|^d8ig4vwr}+HCBZ~O1T9awiK^V*kbD& zOFQ|nSlMNC*IoaIs)eD1I7PPTRjIh4&@y3HvBpRGO`fPbY=pZ4wB6iVukoy^YJ*w_ zmAE-{UTB~3>`r0sEAzT_6>jw5RG6?!ZSgv1nK!;wh6G+Yo~^4}rzOH3?!0VJZwmpl zcFUqIkf7at>c#B7x*V))wh4-s8c?etQ2c?@38bibgN`kIz7D=q2gLdO+AeLC<+x_j zG`zYW#^NGAq)0ALzFJcm&|ngDtSpb&H-xK@%h9vRgv(KeYn_`!k>{P|0+GDlb+w~% zDIgMdQ0d3QSpMT`HJ$nZh>S6sFx*O?)#H~RuDa<)KOI{1F8Jw6j_P7>_;5v(M@Vj_ z*3)!z+ClJdxPXma^6z*1ai%OurY-BOju-HT`ojz5&ia2&)|nv#k`DGGk#1P#lN2)> zqtFa#0!D8)FCZ>TOF^!}c(SL81w|AJ0sWtj#Xm=f0fxA)%>e^I;om^ZF#!emSs{FJ SmdQTAAJfZ!Tk+Pg)V~1`Ht16T literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_up.png b/emoji-uploads/wrank_up.png new file mode 100644 index 0000000000000000000000000000000000000000..bf95c051f81a9d14aadaeebd6512365fbd219b9a GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^+(699!3HFsq+HVlaSA*li-F?8Ak4@xYmNj^P$AhP z$d`ekN|k}3p_zf<=YJsml7XSrfPvvv0t1893f|KGFHWoz=5MO|QIW0I(3R2di=ni&{={s+=885l|p7#Ln9FfdrnU|pA>)8je&vL)YHW=q~g|_ zI|qH497P%)Za>EK#=-jrU)q}kLT?ge>i!-6&zKWG z_xtaw8UhVAzyBQn@oQqgs63;`rc2X*Ud!rdh+wMma8=tRyxJa2LAvNmu-(B&iRw^@Wg9R*g!x$V_18C%d znY??;gCl}8gIz+Je>i71D_SsP2^gfH`f>8?DJ$ literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_up_2.png b/emoji-uploads/wrank_up_2.png new file mode 100644 index 0000000000000000000000000000000000000000..441d61b24623e53c87ab9b66627df235a0e164cc GIT binary patch literal 2174 zcmb_dc~Dd57C*Tr8g>{&L}P=BM1>*>0!lTp)nXom;KJqtd2Q7vvMFG!67B^oqFC`I zjvFX26ts$ft+FZH_^>LAV#N^`Fd%_ckOX2N#3c8=%ejJVVWae*6eC8MDYhl%h=^$V)wY z2wWq(#h)-&jm@(?RkGYpkxjzw>7rM{QMR$FEyxjFd}l5FjvKCz-A=QIP&Sq(RhhfO zNw2jzJEa0?ZEVyACI;R-h%kRg{!DHwm9Vp8^AcQGDB>*x-+Xt|K(1y~5m%s0Bg27? zRWW&;!U;PW!Lpu93e?sMx_-E)6I+xwJGTCGyzd#pVP@c=s`uohuj5MF%0bO*K z7R9OOH*&N?F*X4NUGz>y-s8EwSF;Zplqu6r;hXB6&X@Q~tgEbl0Pk4w0$0ezm`qqe z6{ccVSZ7m4y9-?s2L$qc`EO_Etp48hk?+Vyyko33pCF8>9d?J}FR8tL8^}f9rb$t^ z`thPluSELlINhw_Gq}T5fjH&yu^9*B^9ys?YfF=dTyOEcv|~M4r?@nGO!z@+dLf+H zS;93h?b+k(C9Dg=h2pkqpm<6DPNrV+MM2@j%^d=ERwU<>ImWctsejTDbD1G3Qtu5c zI7yy zK8Q#2QV4mN*xO}%d)g8{+5jRydMk%F)!KBhhd-4k4-Qk9<|)i=e>x&hkAxfZ=jC&d zd26!rR!M5xQiaUVp7d2G+eg#9Z9LjgD#2wd3`8XN%#Ha@P+qd6nsF#;Dtn#h=*7UP zw9P`s1o?#OO~+`$t`VswoniM~6S^x zK;*urM)^KscT@r`o;Fn5{!~wh*@U4FM+YWS5`A4&9&W0g3UYGr+Eb-aT4YlNVfrSr=!Y0%_!py{v*fdi zha}`zt%c)cLmB=#<^-kn9=?X{!6>ab(Vw9=cbz3_pa#YpLh6Gv^a`C?GwBGlfaBq~ zCdqQd7^~Y3v}#823IVQJw%ow|VBFs93D4gPS_^f-T*HWwf&HVWti4krRsW=TU>`W6 zm&LnOxmmriN$>JbTGTHDdU0;snZSO$ZA;>W15lkN+g8@?LQ1%XsZlg!#gnPOCl?wXrIq1)GC4T&vS)?sBzg$6~T(K)jXQ&bCEihp6_FsQK`Fjj4 z*%tnXe=i{X(D5zl6;J`Q1)Um0uz+wL+mcSl;yC12(p~pjaG!RB*C<1GX%~V!-jWaJ zP43)>DNenAICvg(>YYDuc|g4Bu~JO^snO$7JFV_!482&}f`%t+6?4C#%at=|-UbH< z$)IDuUNj{%bbKr@rT;F@>5b>$!zL!L>|!eWLv(?ODL1*}fu>|zxzeA)%M{8W%84Su z+fek)9&L&>hjxXct@)%APjc3s8v!40od^`bfa$r43_;U)b+8j7CXcnAGv?W?bs_~n`e`5_(9+Nzg&R`wEz29R+iJ> lzAZ(}KPSomQHnn!U4U`M|DAu{`YS*k{)W)?m0yao{tHbLpV$BZ literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_up_3.png b/emoji-uploads/wrank_up_3.png new file mode 100644 index 0000000000000000000000000000000000000000..eaed3f74b63dadfb26aaf24b9eb1d13d78e1f9f3 GIT binary patch literal 2652 zcmb_eX;f3!7Cs3V2nY!Z${?deQHlsghBC$?BDD;4sHQ5!YVd(VB8Z4#xS~Q^R1}4P z48cNyRv9dl3N&%iN)a$s&;Sty_xjG>XYX~+{`S7# zg@*Rado01Sh7?brhV230Wtr-O>a@zqhN&|~bPBml6&_~VBGw~NhC(DvA_Js|*` zUk?C$5rAa`l~VvDx&ZKw3V>Gu0H*ZZ$lrIO{%?YJY~OcoXl8gcvF{)@bCk!NL~iRp z*$4jwsxlV$k*h(q?Hz1^{x^=cA2GUif&R`z7i3wrECYuQqjiJ1^OE8YJyL5q_v-t1 z5tMv~)?sIIiH3$Fe@qnuM&O@P{{Klj?@R+2VK=+(aK^TEwW-qb$N7ge{@KJLV((@| zz#ZA_L8%(#Q?TX&JieSoh$F$rD>FhY(1#1M9LySN+S5GIxG(>4cPJ zjk<@rgKVOy`I9{5v6G)ewc1q6*LvqT7m>hR0$#&$N*`ID9J>)J(?Q&!mZ7ZEF&iP+ zYx52=s1_WCAh1$SHG|+s_6!;-&X-iRdwvVTf68ub2_hL;_ZB7wY$|il% z0H88Old3y@K^;-~d+!a6QwYV;=h@LxJ^AaLND$K$%Cws~`e`3scg>Ftt15qGStqn+ zfd1qA@rnk6(HpOYvPvgI#ru9|JJ57*-X>ow#`_0<+0SB})2s!S@zj0d6r%hCucF&C z2K$;%6n^2P1&PMD(F{|oSHz?;^E44R4h|fFME#XYC2KJ>ICA%eptI%}&%6fByghpk z>5Gqg*>QF=1`-jUDF}TpvuFm)O}4<{w;Ha@`f+e<`dFB+Wh3Dif!o;)B#=2@LxDkf zU6Y*=c-Q0#VPOiu0vxafFhCXmdniP%Ioo=5yAgPDY-F^#Idd;CV?}2P;-#iw=ke!@ zQa_757^+X{nYQ*OyVa=C;FLnBO~z2SMNLVuqQ9~7X|bH+R?#`}9U09brWCg=Wa-o4 zc5HoU{A9=WUSO6bo{~;*4rKNla1E$FCy}rcw%XW+5U0b%wI!f|-kr&dt5emzX-eM> zcEistRbs2Sx?tt9UN3@NRr3sBN4%y&-CyzKPkqqgw)v!{mY~_a_iy8)hi;LOdtBZhQykV5R<2Z-0{xX5hg^4U%3$Q zDBa`$|91Q<&aZvxG{oD{6f+g`6mhX$umk&OMbbIHB)J~Qvy$_QKXmc}$#dV7NL?d7 z39tkGNB80t({d9VOqs>q#w}uspjC%_IVw8xe6hJc3A}6ggU=*1)ASZxofUd@CV_#TNu!Ql~;Q=~!D3)bfS0jVrkr#ML+etczJgW1U!Lp23P{uiXgHeJ%Xs z{R$?2lp?h;j{120X7@~^{ave1hDh0~PN8?RMQh2Ip6e4T<3R;dlQlh{2b^~aV_*Sv^`yMRXnRXt} zm&S5xwGU#$@5DYr4#QQMFVUJRo1<_q&YZn!_0#}9-Gi<59+`YmuMfr+e;u3kb*;z; z`C!a7MEUJP?Y6;L>w!BxXi09EZO062)3z3xe}&ffD=RuZZkIN^D1WgiwKo4)^6pl2 zo&gz{-H7cJ6j{9JSaRgH2Ql_2ac3TCC9fx=ZQ>0ewhsRmO4Iu07r18^EG;AHUEdP*;LmGEZ5VCIZ$a1qi@U6hH5fZ>qMU}oK>blge^}s-Q zJifGRJYd7kz#r`w+Nv55rZ)>*d0ZDwPg}m;HJ-TPW^`b>EIGH^x><0%<I01eix!$l?xzgDH!2Mr%1U<1sW*9e z#Y2q)LSHgVSbUXF8*1z2xAep<@xuNrVa@jT`L!pNYT_oBSFYWgjuFkRaac(DS8nn0 zmydMFsZ!naW9XPt_+)3L>VucoDt_j6P%RNfn(JV=k9p+e)v&W$^cFcqQiKUF49V?8 zJBBB1ek`rsj^Bsg0p@Z@GJ6RqAJR$rJYXH&3hCIX|ewz)s7I=yK8N9||SjK2V8;g1gh literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_up_4.png b/emoji-uploads/wrank_up_4.png new file mode 100644 index 0000000000000000000000000000000000000000..0a9d7e9097b0fef702f05429edbf3a36a3a21b43 GIT binary patch literal 1497 zcmb_cdrXs86hB{i1PcfZ8Qn4g#mB-+9t$0hwM^a$)PRQ7fh{ngE>0*gKzVg;LR7Q_ zW&uB-WShhV0*;3k86dDi7&1yEpbb#6O6AoS+ChP~bYEfFe@jf}O>WM)=jQ&-?|1G! z_r{s1P;>nIcmOaD4-1M0Krj~p6JyN8Wjsj1%)az6Y9@fGwSFRSuZDmH9g@PLBLS`+ z1z>#(u%*N7BEZ+~084ZL-zosxv~n6h1i*+E9`xziT;a;hc%)ylj+8vSV&6>J_~VXnxC8SY2lQ8*hwN5EGkl{97x(8 zc!vdm0~2t7x8=Ms5?4++y&8p%#%f0;N7%vSW!Y#fho2V)jC7@ac?74Q3#G}Y6ql5y zkaT%xU>*+$-JR{-3+^(Bh+6#1rFU}@M4i`YiTH}3tC&7-Y)JPPB~Fg@ z^7d`YY*r-4=G>-g*LB%cgdWjl6g473ZDKXUm?ohaO%XR z8$BIL+X*y4?U6Za|Dh&(gpjRFb2Y%Z*Tp~Ewqhj^b#2LWI!~iq72ey+sOq^ayr%)0 zf%gOY=V&h_qrC|ipl+BT3G*T{(x^r((pfuk!6`-MQ5)Dk?c8VuvLzdXXP8tlwf7J+?qWDs^ zCChCkagq&Y7f~#r+J&#WMbwdoEkD*?96 zYYh2t>tT?pIYK74BHxM?wbFWL!HKAw)knJ`wY)du*WFchz@rDqS9*P!yzNz$|DS@oS%I(lRnu{||FeGWQQ%PMhuSZ|D-rx4b z=3xg3f24Tnx$SBDQ9+Pr*1Wm}S;|G4&+=HZlI!Av6YNNeA}}h91gu&_o!jzMcI=g# zOnaPwFA+5qPRhHmD(v^x#r?WP@xatRd7+6}O4UXwg28xN^ZMgqJ->O6*&BL=mq}V@ z_F9y`5KGdwWgqRgtg{o7&gY>gMiOmy{NS!>8>J{mV{tu}j1p;UJDEnS{DVo;C1AFA z;P}>I2=pC#j=kuID8@sn6}S))QC#)y>la7jIrEJ7;*kIB_kTLqtp5tgFZAjA@&o)5 z9xNe1|A`s?U*}B#gsoH#--+e?mQwyp{Y`Qk0%M>B0Z!|bQ^{8JJYXX{I4Y?5B>mdo Djr}Uj literal 0 HcmV?d00001 diff --git a/emoji-uploads/wrank_up_5.png b/emoji-uploads/wrank_up_5.png new file mode 100644 index 0000000000000000000000000000000000000000..ccbb1339a1969102cc315c8041e5af0ab609fc57 GIT binary patch literal 1899 zcmb_dX;70_6utQf7=b`g5wuvA3UxyxASydVg$xp?RUsfGiW0R#ASf6R@}ZVa7oiPh z*I+G$*oMU|k;)Q_sEL4OI&wuT7rsEIa%$sxOy*KxsbMM@FUk1^9 ziTW$`0f0#L+pz}#hfy35^e{NU5k_IKIM$CI4`8tL?Z!dDMRV-XD$;LH06>y00D2B! zW&y)NfKz0EAtr!J9)KB}&uG{Mp!b@(!+Y=P=Px^Z;uKNvpvTj_Rz(X+L>lo9OO-UQ zJaE8~y_v{wcq#sxo!4&E)w>C=UQ|=nyX~@Vi1_Ua*A*(* z>3|0kc)b<-zR0^p+NCPDHV;u8FhBo6a-rCLt06)@;!lZ<{EWgh`+r3wxV)!)5qVWO zUm;hI->=^Y6XL21W)F{sWs1Vz(aJj;&QOXU6+{ALhh~SL{rm?K4BaH*aMF!w1OnJtAqYyag1r`LCHX79QpGtd zoucIrBqzJut~Wc@ZtZVuv9vieVC|madZ)8_jp5Z}sR^j$h^<=wSCM=qbN_5n_~7zB zRVL?-+t`{KzxLXR!zO3}5?kd{k)=r)8IPpfalhB*&2Ns!y6Jq&}bO$a)cLp{w+DrV5IA z-^IkEMTz}F6v!UKtBv`FHKzpG;l_CMySAJ*>DOtzW=D!WeAD`9$=RMLTtNJxJYDp) zi{&;XlXwltT0-^8qiv6znmR}Zph_s{>8ifzP>o365O1v?efUHlR1z84S1%`hKjfvi zu6V)Lb-sYrXlZ2=nY|>`JQT-txq+tKVTb6VgVoEQzaR7134`VN^L8$d?F$5b_o?sz=E&!M>A4kM%;LShJhp(=Gm-oRTU(#fEixv&M0j zjO3N8nlE+6u$E2TFc>XMa|`6m)~nhA92=7FY;Y8$w3kHlrVl&l+_tCj&(IWZfr`c$ zSxV???$#&Q8C*F*8r@-kI?msxa!r`lT3!Fg1OtvQzFWBX!_DTLzE=`$*X(-8KCk4o zDsAd7?zqAJB`sKcp{x#`9;Hu}dX{pwwLW~M$t3kO>2 zO!`wYlBuF+X;*I2+C3)l4m!%?j5$%GzK0z*=U|3}_n4Zmu5*>j>jK8^QBvI!iFXGg zD{qU7s%gBO4wt!_15@i{KXP1MmGiCv?J;hRh?G~DTQ)8W_WrYXsD<9SK&Zd6VK|jb zJj5-Yi;wUiL8>+3)2FuECrL>}boNA+#+3Kk7lfQXo;h!n0>YO1+btCkG?LRT_jHKB z-;=ZT;M{;ngLqx)3Y*bIT}m~jal$}YH_n<(6hG4xGd|{$f~WEgSvu-4UPDpV1kauo zA*<x7l8-7l=QE$`OhGIH2n%%Z&Hm6NJKGUi@KAxquhsi_HU;r*xCR9 literal 0 HcmV?d00001 diff --git a/messages/emojis.json b/messages/emojis.json index dc97d6a..5f14e99 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -1,43 +1,41 @@ { - "capella": "<:capella:1511020911027814453>", - "procyon": "<:procyon:1511020943323955301>", - "bl": "<:bl:1511014332685881364>", - "fb": "<:fb:1511020923510194428>", - "fs": "<:fs:1511020931542417459>", - "fa": "<:fa:1511020918929887434>", - "fg": "<:fg:1511020927461097482>", - "gl": "<:gl:1511020935463833711>", - "dm": "<:dm:1511020914974658612>", - "wi": "<:wi:1511020959706910913>", - "wa": "<:wa:1511020955231715448>", - "wrank_up": "", - "wrank_down": "", - "atk": "", - "def": "", - "heal": "", - "wrank_neutral": "", - "wrank_1": "", - "wrank_1_gold": "", - "wrank_2": "", - "wrank_2_gold": "", - "wrank_3": "", - "wrank_3_gold": "", - "wrank_4": "", - "wrank_4_gold": "", - "wrank_5": "", - "wrank_5_gold": "", - "wrank_6": "", - "wrank_6_gold": "", - "wrank_7": "", - "wrank_7_gold": "", - "wrank_8": "", - "wrank_8_gold": "", - "wrank_9": "", - "wrank_9_gold": "", - "wrank_10": "", - "wrank_10_gold": "", - "kd": "<:kd:1511020939226124339>", - "score": "<:score:1511020950718513172>", - "rank": "<:rank:1511020947019137187>", - "borrowed": "<:borrowed:1511020906754085057>" + "bl": "<:bl:1511906439516651561>", + "borrowed": "<:borrowed:1511906443245391944>", + "capella": "<:capella:1511906447167062137>", + "dm": "<:dm:1511906450866180126>", + "fa": "<:fa:1511906454506967242>", + "fb": "<:fb:1511906458231377950>", + "fg": "<:fg:1511906461977022605>", + "fs": "<:fs:1511906465798029423>", + "gl": "<:gl:1511906470684524594>", + "kd": "<:kd:1511906474497146983>", + "luminous_bringer": "<:luminous_bringer:1511906480184492263>", + "procyon": "<:procyon:1511906483993055295>", + "rank": "<:rank:1511906488380293180>", + "score": "<:score:1511906491903250525>", + "storm_bringer": "<:storm_bringer:1511906496097554594>", + "wa": "<:wa:1511906499889467492>", + "wi": "<:wi:1511906503647563807>", + "wrank_1": "<:wrank_1:1511906507485085736>", + "wrank_1_gold": "<:wrank_1_gold:1511906510806978742>", + "wrank_2": "<:wrank_2:1511906514745430217>", + "wrank_2_gold": "<:wrank_2_gold:1511906518386212864>", + "wrank_3": "<:wrank_3:1511906522265944154>", + "wrank_3_gold": "<:wrank_3_gold:1511906526204530690>", + "wrank_4": "<:wrank_4:1511906530692173915>", + "wrank_4_gold": "<:wrank_4_gold:1511906534790266883>", + "wrank_5": "<:wrank_5:1511906539223388322>", + "wrank_5_gold": "<:wrank_5_gold:1511906543342452837>", + "wrank_down": "<:wrank_down:1511906547104616643>", + "wrank_down_1": "<:wrank_down_1:1511906550698999909>", + "wrank_down_2": "<:wrank_down_2:1511906554507694120>", + "wrank_down_3": "<:wrank_down_3:1511906558231969792>", + "wrank_down_4": "<:wrank_down_4:1511906562011304007>", + "wrank_down_5": "<:wrank_down_5:1511906565630984273>", + "wrank_up": "<:wrank_up:1511906568877117576>", + "wrank_up_1": "<:wrank_up_1:1511906573537120287>", + "wrank_up_2": "<:wrank_up_2:1511906577970364536>", + "wrank_up_3": "<:wrank_up_3:1511906581711945909>", + "wrank_up_4": "<:wrank_up_4:1511906585503338616>", + "wrank_up_5": "<:wrank_up_5:1511906588921954325>" } \ No newline at end of file diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts index b658158..80ea997 100644 --- a/scripts/upload-emojis.ts +++ b/scripts/upload-emojis.ts @@ -1,31 +1,42 @@ /** * Bulk emoji upload script - * Usage: npx ts-node scripts/upload-emojis.ts [emoji_dir] - * - * Place emoji PNG files in a directory named after the emoji key. - * Example: fb.png, wi.png, capella.png, wrank_1.png, wrank_1_gold.png + * Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir] * + * Distributes emojis across a pool of donor servers (round-robin by available capacity). + * Each emoji is unique across all servers — no duplicates. * Automatically updates messages/emojis.json with the uploaded emoji IDs. + * + * Required .env vars: + * DISCORD_TOKEN — bot token + * EMOJI_DONOR_GUILDS — comma-separated donor server IDs + * e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333 */ import { REST, Routes } from "discord.js"; import fs from "fs"; import path from "path"; - // Load .env manually since we're outside the bot + // Load .env manually since we're outside the bot runtime const envPath = path.join(__dirname, "../.env"); if (fs.existsSync(envPath)) { for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { const [key, ...rest] = line.split("="); - if (key && rest.length) process.env[key.trim()] = rest.join("=").trim(); + if (key?.trim() && rest.length) process.env[key.trim()] = rest.join("=").trim(); } } - const TOKEN = process.env.DISCORD_TOKEN!; - const GUILD_ID = process.env.GUILD_ID!; + const TOKEN = process.env.DISCORD_TOKEN!; + const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); - if (!TOKEN || !GUILD_ID) { - console.error("❌ DISCORD_TOKEN and GUILD_ID must be set in .env"); + if (!TOKEN) { + console.error("❌ DISCORD_TOKEN must be set in .env"); + process.exit(1); + } + if (DONOR_GUILD_IDS.length === 0) { + console.error("❌ EMOJI_DONOR_GUILDS must be set in .env (comma-separated guild IDs)"); process.exit(1); } @@ -40,6 +51,48 @@ const rest = new REST({ version: "10" }).setToken(TOKEN); + interface GuildEmojiSlot { + guildId: string; + name: string; // guild name for display + existing: Map; // emojiName → emojiId + capacity: number; + } + + // Compute max emojis based on Nitro boost tier + function maxEmojisForTier(premiumTier: number): number { + switch (premiumTier) { + case 1: return 100; + case 2: return 150; + case 3: return 250; + default: return 50; + } + } + + async function fetchGuildSlots(guildIds: string[]): Promise { + const slots: GuildEmojiSlot[] = []; + + for (const guildId of guildIds) { + try { + const [guild, emojis] = await Promise.all([ + rest.get(Routes.guild(guildId)) as Promise, + rest.get(Routes.guildEmojis(guildId)) as Promise, + ]); + + const maxEmojis = maxEmojisForTier(guild.premium_tier ?? 0); + const existingMap = new Map(emojis.map((e: any) => [e.name, e.id])); + const capacity = maxEmojis - emojis.length; + const guildName = guild.name ?? guildId; + + console.log(`🏠 ${guildName} (${guildId}): ${emojis.length}/${maxEmojis} emojis, ${capacity} slots free`); + slots.push({ guildId, name: guildName, existing: existingMap, capacity }); + } catch (err: any) { + console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`); + } + } + + return slots; + } + async function uploadEmojis(): Promise { const files = fs.readdirSync(emojiDir).filter((f) => [".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase()) @@ -58,42 +111,85 @@ console.warn("⚠️ Could not load emojis.json — will create fresh mapping."); } - console.log(`📁 Found ${files.length} file(s) in ${emojiDir}\n`); + console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`); + console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`); - // Fetch existing guild emojis to skip duplicates - const existing = await rest.get(Routes.guildEmojis(GUILD_ID)) as any[]; - const existingMap = new Map(existing.map((e: any) => [e.name, e.id])); + const guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS); + + if (guildSlots.length === 0) { + console.error("❌ No accessible donor guilds found. Check EMOJI_DONOR_GUILDS and bot membership."); + process.exit(1); + } + + // Build global deduplication map across all donor guilds + const globalExisting = new Map(); // emojiName → formatted string + for (const slot of guildSlots) { + for (const [name, id] of slot.existing) { + globalExisting.set(name, `<:${name}:${id}>`); + } + } + + const totalCapacity = guildSlots.reduce((sum, s) => sum + s.capacity, 0); + console.log(`\n📊 ${globalExisting.size} emoji(s) already exist · ${totalCapacity} slots available across all servers\n`); + + if (totalCapacity === 0) { + console.error("❌ All donor servers are full! Add more servers to EMOJI_DONOR_GUILDS."); + process.exit(1); + } let uploaded = 0; let skipped = 0; let failed = 0; + // Round-robin slot picker — distributes load evenly across guilds + let slotIndex = 0; + function nextAvailableSlot(): GuildEmojiSlot | null { + const start = slotIndex; + do { + const slot = guildSlots[slotIndex % guildSlots.length]; + slotIndex++; + if (slot.capacity > 0) return slot; + } while (slotIndex % guildSlots.length !== start % guildSlots.length); + // Fallback: find any with capacity (in case loop exited without finding one) + return guildSlots.find((s) => s.capacity > 0) ?? null; + } + for (const file of files) { const emojiName = path.basename(file, path.extname(file)); const filePath = path.join(emojiDir, file); const ext = path.extname(file).toLowerCase(); const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png"; - if (existingMap.has(emojiName)) { - const formatted = `<:${emojiName}:${existingMap.get(emojiName)}>`; - emojiMap[emojiName] = formatted; - console.log(`⏭️ Already exists: ${emojiName} → ${formatted}`); + // Already exists in the pool — update map and skip + if (globalExisting.has(emojiName)) { + emojiMap[emojiName] = globalExisting.get(emojiName)!; + console.log(`⏭️ Already exists: ${emojiName} → ${emojiMap[emojiName]}`); skipped++; continue; } + const slot = nextAvailableSlot(); + if (!slot) { + console.error(`❌ All slots full — could not upload: ${emojiName}`); + console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`); + failed++; + continue; + } + try { - const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; - const result = await rest.post(Routes.guildEmojis(GUILD_ID), { + const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; + const result = await rest.post(Routes.guildEmojis(slot.guildId), { body: { name: emojiName, image: base64 }, }) as any; const formatted = `<:${emojiName}:${result.id}>`; emojiMap[emojiName] = formatted; - console.log(`✅ Uploaded: ${emojiName} → ${formatted}`); + slot.capacity--; + + console.log(`✅ Uploaded: ${emojiName} → ${formatted} [${slot.name}]`); uploaded++; - // Avoid rate limiting + // Rate limit buffer await new Promise((r) => setTimeout(r, 600)); } catch (err: any) { console.error(`❌ Failed: ${emojiName} — ${err.message}`); @@ -105,6 +201,11 @@ console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); console.log(`💾 messages/emojis.json updated`); + console.log(`\nSlot usage after upload:`); + for (const slot of guildSlots) { + const used = slot.existing.size + (uploaded > 0 ? slot.existing.size : 0); + console.log(` ${slot.name}: ${slot.capacity} slots remaining`); + } } uploadEmojis().catch(console.error); \ No newline at end of file diff --git a/src/commands/tg.ts b/src/commands/tg.ts index 67e60ad..b5f193b 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -69,6 +69,7 @@ export function buildTgCommand(): SlashCommandBuilder { ) .addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll") .addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)) + .addBooleanOption((o) => o.setName("simulate_close").setDescription("Simulate poll lock").setRequired(false)) ) .addSubcommand((s) => s.setName("unlock").setDescription("Unlock the active poll")) .addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening") @@ -77,7 +78,22 @@ export function buildTgCommand(): SlashCommandBuilder { .addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false)) .addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").setRequired(false)) ) - .addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk")) + .addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk") + .addStringOption(opt => + opt.setName("target") + .setDescription("What to reload") + .setRequired(false) + .addChoices( + { name: "All", value: "all" }, + { name: "Config", value: "config" }, + { name: "Messages", value: "messages" }, + { name: "Emojis", value: "emojis" }, + { name: "Characters", value: "characters" }, + { name: "W.Rank", value: "wrank" }, + { name: "Poll", value: "poll" }, + ) + ) + ) .addSubcommand((s) => s.setName("status").setDescription("Show current poll and config status")) .addSubcommand((s) => s.setName("set-message").setDescription("Set public message override for a user") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index 9d607c9..dc384d4 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -1,15 +1,23 @@ -import { ButtonInteraction, TextChannel } from "discord.js"; +import { + ButtonInteraction, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ActionRowBuilder, + TextChannel +} from "discord.js"; import { cfg } from "@systems/config"; import { pollReplyAndDelete } from "../utils"; import { resolveUser } from "@systems/users"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { resolveNation } from "@systems/nations"; import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll"; +import { persist } from "@systems/pollPersistence" import { showConflictEmbed } from "@systems/conflict"; import { getCharacters } from "@systems/characters"; import { getImpersonation } from "@systems/impersonate"; import { format } from "@format"; import { Character } from "@src/types"; +import { modals } from "@handlers/modals"; const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); @@ -63,10 +71,21 @@ async function handleCharacterConflict( const slot = [...polls.keys()][0]; const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; + // await interaction.followUp({ + // content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + // // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, + // ephemeral: true + // }); + const { buildCharSelectButtons } = require("@systems/charSelect"); + const buttons = buildCharSelectButtons(userKey ?? "", { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: char.name, + appendToCustomId: ":yes", + }); await interaction.followUp({ - content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, - // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, - ephemeral: true + content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + components: buttons, + ephemeral: true, }); return true; } @@ -181,6 +200,7 @@ export async function handleButton(interaction: ButtonInteraction): Promise= LOCK_AT; if (locked) state.locked = true; + persist.save(polls); const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; const msgContent = ephemeralMsg @@ -194,4 +214,71 @@ export async function handleButton(interaction: ButtonInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const user = await resolveUser(member); + if (!user.userKey) { + await interaction.reply({ content: "❌ You are not registered in the system.", ephemeral: true }); + return; + } + + const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; + const state = slot !== undefined ? polls.get(slot) : null; + + if (!state?.lockedYesKeys?.has(user.userKey)) { + await interaction.reply({ content: "❌ You weren't in this TG.", ephemeral: true }); + return; + } + + // Slot is known from the poll — go straight to modal, no select needed + await interaction.showModal(modals.buildScoreModal(user.userKey, slot!)); +} + +// export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise { +// await interaction.deferReply({ ephemeral: true }); + +// const member = await interaction.guild!.members.fetch(interaction.user.id); +// const user = await resolveUser(member); +// if (!user.userKey) { +// await interaction.editReply("❌ You are not registered in the system."); +// return; +// } + +// // Find the poll this message belongs to +// const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; +// const state = slot !== undefined ? polls.get(slot) : null; + +// // Enforce: only players who were locked in at TG start can submit +// if (!state?.lockedYesKeys?.has(user.userKey)) { +// await interaction.editReply("❌ You weren't in this TG."); +// return; +// } + +// // Build slot selector — all valid slots, with the active TG pre-selected +// const validSlots = cfg("slots").map((s) => s.tgHour) as number[]; +// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + +// const select = new StringSelectMenuBuilder() +// .setCustomId(`score_slot_select:${user.userKey}`) +// .setPlaceholder("Select TG slot") +// .addOptions( +// validSlots.map((h) => +// new StringSelectMenuOptionBuilder() +// .setLabel(`${String(h).padStart(2, "0")}:00 TG`) +// .setValue(String(h)) +// .setDefault(h === activeSlot) +// ) +// ); + +// const row = new ActionRowBuilder().addComponents(select); + +// await interaction.editReply({ +// content: "Which TG are you submitting for?", +// components: [row], +// }); +// } \ No newline at end of file diff --git a/src/handlers/interactions.ts b/src/handlers/interactions.ts index ee01719..c71967c 100644 --- a/src/handlers/interactions.ts +++ b/src/handlers/interactions.ts @@ -1,5 +1,5 @@ -import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; -import { handleButton } from "@handlers/buttons"; +import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel, StringSelectMenuInteraction } from "discord.js"; +import { handleButton, handleScoreSubmitButton } from "@handlers/buttons"; import { handleTgCommand } from "@commands/tg"; import { handleTgConfigCommand } from "@commands/tgConfig"; import { handleBorrowAcceptButton } from "@subcommands/char/accept"; @@ -13,6 +13,7 @@ import { polls, updatePollMessage } from "@systems/poll"; import { cfg } from "@systems/config"; import { resolveMessage, nowFormatted } from "@systems/messages"; import { format } from "@format"; +import { modals } from "@handlers/modals"; import fs from "fs"; import path from "path"; @@ -77,19 +78,27 @@ async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise { publicMessage: publicMsg ?? undefined, }; + // Find and reuse existing vote ID — avoids duplicate entries + let existingVoteId: string | null = null; for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { if (entry.userKey === userKey) { + if (!existingVoteId) existingVoteId = id; state.yes.delete(id); state.no.delete(id); } } + const voteId = existingVoteId ?? btn.user.id; if (prevVoteType === "yes") { - state.yes.set(`switch_reclaim:${userKey}`, voteEntry); + state.yes.set(voteId, voteEntry); } else { - state.no.set(`switch_reclaim:${userKey}`, voteEntry); + state.no.set(voteId, voteEntry); } + console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`); + console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`)); + console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`)); + const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot!); } @@ -112,7 +121,9 @@ export async function handleInteraction(interaction: Interaction): Promise if (interaction.isButton()) { const btn = interaction as ButtonInteraction; + console.log("[interactions] interaction btnId:", btn.customId); if (btn.customId.startsWith("conflict_")) { + console.log("[interactions] routing to conflict handler:", btn.customId); return await handleConflictButton(btn); } @@ -134,9 +145,27 @@ export async function handleInteraction(interaction: Interaction): Promise return await handleBorrowDeclineButton(btn, ownerKey, requesterKey); } + if (btn.customId === "tg_score_submit") { + return await handleScoreSubmitButton(btn); + } + return await handleButton(btn); } + if (interaction.isModalSubmit()) { + return await modals.handleModal(interaction); + } + + if (interaction.isStringSelectMenu()) { + const sel = interaction as StringSelectMenuInteraction; + if (sel.customId.startsWith("score_slot_select:")) { + const userKey = sel.customId.split(":")[1]; + const slot = parseInt(sel.values[0], 10); + await sel.showModal(modals.buildScoreModal(userKey, slot)); + return; + } + } + if (interaction.isChatInputCommand()) { const cmd = interaction as ChatInputCommandInteraction; if (cmd.commandName === "tg") await handleTgCommand(cmd); diff --git a/src/handlers/modals.ts b/src/handlers/modals.ts new file mode 100644 index 0000000..495e337 --- /dev/null +++ b/src/handlers/modals.ts @@ -0,0 +1,156 @@ +import { + ModalSubmitInteraction, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ActionRowBuilder, +} from "discord.js"; +import { resolveUser } from "@systems/users"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { score } from "@subcommands/score/submitCore"; +import { format } from "@format"; + +// ─── Modal IDs ──────────────────────────────────────────────────────────────── +// +// score_submit: — score submission modal, slot baked into the customId +// so we don't need to pass state through the modal itself + +export namespace modals { + + // ─── Builder ─────────────────────────────────────────────────────────────── + + /** + * Build the score submission modal for a given userKey + slot. + * Title shows the active character so the user knows what they're submitting for. + */ + export function buildScoreModal(userKey: string, slot: number): ModalBuilder { + const { char } = getEffectiveCharacter(userKey); + // const charLabel = char ? format.char(char) : "your character"; + const charLabel = char ? format.char(char, { emoji: false }) : "your character"; + + const modal = new ModalBuilder() + .setCustomId(`score_submit:${slot}`) + .setTitle(`Score for ${charLabel} — ${String(slot).padStart(2, "0")}:00`); + + const ptsInput = new TextInputBuilder() + .setCustomId("pts") + .setLabel("Points") + .setStyle(TextInputStyle.Short) + .setPlaceholder("e.g. 3000") + .setRequired(true); + + const kdInput = new TextInputBuilder() + .setCustomId("kd") + .setLabel("Kills / Deaths (e.g. 5/2)") + .setStyle(TextInputStyle.Short) + .setPlaceholder("5/2") + .setRequired(false); + + const atkDefInput = new TextInputBuilder() + .setCustomId("atkdef") + .setLabel("ATK / DEF (e.g. 120/80)") + .setStyle(TextInputStyle.Short) + .setPlaceholder("120/80") + .setRequired(false); + + const healInput = new TextInputBuilder() + .setCustomId("heal") + .setLabel("Heal") + .setStyle(TextInputStyle.Short) + .setPlaceholder("e.g. 500") + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(ptsInput), + new ActionRowBuilder().addComponents(kdInput), + new ActionRowBuilder().addComponents(atkDefInput), + new ActionRowBuilder().addComponents(healInput), + ); + + return modal; + } + + // ─── Parser helpers ──────────────────────────────────────────────────────── + + function parseSlashPair(raw: string | null): [number, number] | null { + if (!raw) return null; + const parts = raw.trim().split("/"); + if (parts.length !== 2) return null; + const a = parseInt(parts[0], 10); + const b = parseInt(parts[1], 10); + if (isNaN(a) || isNaN(b)) return null; + return [a, b]; + } + + function parseOptionalInt(raw: string | null): number | undefined { + if (!raw) return undefined; + const n = parseInt(raw.trim(), 10); + return isNaN(n) ? undefined : n; + } + + // ─── Handler ─────────────────────────────────────────────────────────────── + + export async function handleModal(interaction: ModalSubmitInteraction): Promise { + if (interaction.customId.startsWith("score_submit:")) { + await handleScoreSubmit(interaction); + return; + } + // Future modals routed here by customId prefix + } + + async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise { + await interaction.deferReply({ ephemeral: true }); + + const slotStr = interaction.customId.split(":")[1]; + const slot = parseInt(slotStr, 10); + if (isNaN(slot)) { + await interaction.editReply("❌ Invalid slot in modal."); + return; + } + + const member = await interaction.guild!.members.fetch(interaction.user.id); + const user = await resolveUser(member); + if (!user.userKey) { + await interaction.editReply("❌ You are not registered in the system."); + return; + } + + // Parse fields + const ptsRaw = interaction.fields.getTextInputValue("pts"); + const kdRaw = interaction.fields.getTextInputValue("kd") || null; + const atkDefRaw = interaction.fields.getTextInputValue("atkdef") || null; + const healRaw = interaction.fields.getTextInputValue("heal") || null; + + const pts = parseInt(ptsRaw.trim(), 10); + if (isNaN(pts)) { + await interaction.editReply("❌ Points must be a number."); + return; + } + + const kd = parseSlashPair(kdRaw); + const atkDef = parseSlashPair(atkDefRaw); + const heal = parseOptionalInt(healRaw); + + if (kdRaw && !kd) { + await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`."); + return; + } + if (atkDefRaw && !atkDef) { + await interaction.editReply("❌ ATK/DEF must be in `atk/def` format, e.g. `120/80`."); + return; + } + + const result = await score.submitForUser({ + userKey: user.userKey, + pts, + slot, + k: kd?.[0], + d: kd?.[1], + atk: atkDef?.[0], + def: atkDef?.[1], + heal, + }); + + await interaction.editReply(result.message); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index fbae39c..745a1bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,16 @@ -import { Client, GatewayIntentBits, REST, Routes } from "discord.js"; -import { loadConfig, cfg } from "./systems/config"; -import { loadMessages } from "./systems/messages"; -import { loadEmojis } from "./systems/emojis"; -import { loadCharacters } from "./systems/characters"; -import { loadWRank } from "./systems/wrank"; -import { scheduleSlots } from "./systems/slots"; -import { postPoll, polls } from "./systems/poll"; -import { handleInteraction } from "./handlers/interactions"; -import { buildTgCommand } from "./commands/tg"; -import { buildTgConfigCommand } from "./commands/tgConfig"; -import { TGSlot } from "./types"; +import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js"; +import { loadConfig, cfg } from "@systems/config"; +import { loadMessages } from "@systems/messages"; +import { loadEmojis } from "@systems/emojis"; +import { loadCharacters } from "@systems/characters"; +import { loadWRank } from "@systems/wrank"; +import { scheduleSlots } from "@systems/slots"; +import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll"; +import { handleInteraction } from "@handlers/interactions"; +import { buildTgCommand } from "@commands/tg"; +import { buildTgConfigCommand } from "@commands/tgConfig"; +import { TGSlot } from "@src/types"; +import { persist } from "@systems/pollPersistence" const TOKEN = process.env.DISCORD_TOKEN!; const CLIENT_ID = process.env.CLIENT_ID!; @@ -28,21 +29,35 @@ async function registerCommands(): Promise { } async function onPollOpen(slot: TGSlot): Promise { - const channelId = cfg("pollChannelId"); - const channel = await client.channels.fetch(channelId) as any; + const channel = await client.channels.fetch(cfg("pollChannelId")) as any; if (!channel) return console.error("Poll channel not found."); await postPoll(channel, slot); } +// Fires at tgHour exactly (e.g. 20:00) — voting closes, lockedYesKeys snapshotted +async function onPollLock(slot: TGSlot): Promise { + const state = polls.get(slot.tgHour); + if (!state || state.locked) return; + + lockPoll(slot.tgHour); + + const channel = await client.channels.fetch(cfg("pollChannelId")) as any; + if (!channel) return; + + // Buttons disabled, no submit button yet — that comes at close + await updatePollMessage(channel, slot.tgHour); + console.log(`[${new Date().toISOString()}] Poll locked for ${slot.tgHour}:00.`); +} + +// Fires at tgHour + closesAfter (e.g. 20:35) — TG ended, reveal Submit Score async function onPollClose(slot: TGSlot): Promise { const state = polls.get(slot.tgHour); if (!state) return; - state.locked = true; - const channelId = cfg("pollChannelId"); - const channel = await client.channels.fetch(channelId) as any; + + const channel = await client.channels.fetch(cfg("pollChannelId")) as any; if (!channel) return; - const { updatePollMessage } = require("./systems/poll"); - await updatePollMessage(channel, slot.tgHour); + + await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`); } @@ -51,27 +66,36 @@ client.on("interactionCreate", handleInteraction); client.once("clientReady", async () => { console.log(`Logged in as ${client.user!.tag}`); - // Load all data loadConfig(); loadMessages(); loadEmojis(); loadCharacters(); loadWRank(); - // Warm member cache + const restored = persist.load(); + if (restored) { + for (const [slot, state] of restored) polls.set(slot, state); + + // Re-render all restored poll messages + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + for (const slot of polls.keys()) { + const state = polls.get(slot)!; + await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null); + } + console.log("Poll state restored and messages re-rendered."); + } + const guild = await client.guilds.fetch(GUILD_ID); await guild.members.fetch(); console.log(`Member cache warmed: ${guild.members.cache.size} members`); - // Register commands if --register flag passed if (process.argv.includes("--register")) { await registerCommands(); } - // Schedule slots - scheduleSlots(client, onPollOpen, onPollClose); + scheduleSlots(client, onPollOpen, onPollLock, onPollClose); console.log("Bot ready."); }); -client.login(TOKEN); +client.login(TOKEN); \ No newline at end of file diff --git a/src/scheduler.ts b/src/scheduler.ts new file mode 100644 index 0000000..b1fdcef --- /dev/null +++ b/src/scheduler.ts @@ -0,0 +1,33 @@ +import cron from "node-cron"; +import { TextChannel } from "discord.js"; + +// Lock poll at TG start (20:00), reveal Submit Score button at TG end (20:35) +// Runs daily — no-ops silently if no poll is active for that slot. + +cron.schedule("0 20 * * *", async () => { + const slot = 20; + const state = polls.get(slot); + if (!state || state.locked) return; + + lockPoll(slot); + + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot, cfg("lockMessage")); + console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`); +}); + +cron.schedule("35 20 * * *", async () => { + const slot = 20; + const state = polls.get(slot); + if (!state) return; + + const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot, undefined, true); // showSubmit = true + console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`); +}); + +// ─── NOTE on future slots ───────────────────────────────────────────────────── +// +// Right now only slot 20 has an active poll. When we add more votable slots, +// pull the active slot from cfg("slots").filter(s => s.active) and schedule +// dynamically, or make the cron time configurable in config.json. \ No newline at end of file diff --git a/src/subcommands/poll/lock.ts b/src/subcommands/poll/lock.ts index a92b03a..cff9ed6 100644 --- a/src/subcommands/poll/lock.ts +++ b/src/subcommands/poll/lock.ts @@ -1,21 +1,30 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../../systems/config"; -import { polls, updatePollMessage } from "../../systems/poll"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { polls, lockPoll, updatePollMessage } from "@systems/poll"; +import { replyAndDelete } from "@utils"; export async function handleLock(interaction: ChatInputCommandInteraction): Promise { - const oneTimeMsg = interaction.options.getString("message") ?? undefined; - const slot = getActiveSlot(); - if (!slot) return void replyAndDelete(interaction, "❌ No active poll found."); + const oneTimeMsg = interaction.options.getString("message") ?? undefined; + const simulateClose = interaction.options.getBoolean("simulate_close") ?? false; + const slot = getActiveSlot(); + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); - const state = polls.get(slot)!; - state.locked = true; + // Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron + lockPoll(slot); const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + + if (simulateClose) { + // Simulate TG end: show Submit Score button (same path as onPollClose cron) + await updatePollMessage(channel, slot, oneTimeMsg, true); + return void replyAndDelete(interaction, "🔒 Poll locked + 📊 Submit Score button shown (simulate close)."); + } + + // Normal lock: voting closes, no submit button yet await updatePollMessage(channel, slot, oneTimeMsg); return void replyAndDelete(interaction, "🔒 Poll locked."); } function getActiveSlot(): number | undefined { return [...polls.keys()][0]; -} +} \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts b/src/subcommands/poll/reload.ts index b332963..b90f629 100644 --- a/src/subcommands/poll/reload.ts +++ b/src/subcommands/poll/reload.ts @@ -1,12 +1,88 @@ -import { ChatInputCommandInteraction } from "discord.js"; -import { loadMessages } from "../../systems/messages"; -import { loadEmojis } from "../../systems/emojis"; -import { loadCharacters } from "../../systems/characters"; -import { replyAndDelete } from "../../utils"; +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { loadMessages } from "@systems/messages"; +import { loadEmojis } from "@systems/emojis"; +import { loadCharacters } from "@systems/characters"; +import { loadWRank } from "@systems/wrank"; +import { loadConfig, cfg } from "@systems/config"; +import { polls, updatePollMessage } from "@systems/poll"; +import { persist } from "@systems/pollPersistence"; +import { replyAndDelete } from "@utils"; + +const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const; +type Reloadable = typeof RELOADABLE[number]; export async function handleReload(interaction: ChatInputCommandInteraction): Promise { - loadMessages(); // reloads global.json, usermap.json, users/*.json - loadEmojis(); // reloads emojis.json - loadCharacters(); // reloads characters.json and accounts.json - return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk."); -} \ No newline at end of file + const target = (interaction.options.getString("target") ?? "all") as Reloadable; + + const reloaded: string[] = []; + + const should = (k: Reloadable) => target === "all" || target === k; + + if (should("config")) { loadConfig(); reloaded.push("config"); } + if (should("messages")) { loadMessages(); reloaded.push("messages"); } + if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); } + if (should("characters")) { loadCharacters(); reloaded.push("characters"); } + if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } + + // Re-render active poll message(s) so embed reflects reloaded data + if (should("poll") || should("emojis") || should("all")) { + const channelId = cfg("pollChannelId"); + if (channelId) { + try { + // Restore from disk first if reloading poll specifically + if (should("poll")) { + const restored = persist.load(); + if (restored) { + polls.clear(); + for (const [slot, state] of restored) polls.set(slot, state); + } + } + if (polls.size > 0) { + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + for (const slot of polls.keys()) { + const state = polls.get(slot)!; + const showSubmit = state.locked && state.confirmed === null; + await updatePollMessage(channel, slot, undefined, showSubmit); + } + reloaded.push("poll message"); + } + } catch (err) { + console.error("Failed to re-render poll message on reload:", err); + } + } + } + + return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`); +} + +// export async function handleReload(interaction: ChatInputCommandInteraction): Promise { +// const target = (interaction.options.getString("target") ?? "all") as Reloadable; + +// const reloaded: string[] = []; + +// const should = (k: Reloadable) => target === "all" || target === k; + +// if (should("config")) { loadConfig(); reloaded.push("config"); } +// if (should("messages")) { loadMessages(); reloaded.push("messages"); } +// if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); } +// if (should("characters")) { loadCharacters(); reloaded.push("characters"); } +// if (should("wrank")) { loadWRank(); reloaded.push("wrank"); } + +// // Re-render active poll message(s) so embed reflects reloaded data +// if (should("poll") || should("emojis") || should("all")) { +// const channelId = cfg("pollChannelId"); +// if (channelId && polls.size > 0) { +// try { +// const channel = await interaction.client.channels.fetch(channelId) as TextChannel; +// for (const slot of polls.keys()) { +// await updatePollMessage(channel, slot); +// } +// reloaded.push("poll message"); +// } catch (err) { +// console.error("Failed to re-render poll message on reload:", err); +// } +// } +// } + +// return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`); +// } \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts.bak b/src/subcommands/poll/reload.ts.bak new file mode 100644 index 0000000..b332963 --- /dev/null +++ b/src/subcommands/poll/reload.ts.bak @@ -0,0 +1,12 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { loadMessages } from "../../systems/messages"; +import { loadEmojis } from "../../systems/emojis"; +import { loadCharacters } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; + +export async function handleReload(interaction: ChatInputCommandInteraction): Promise { + loadMessages(); // reloads global.json, usermap.json, users/*.json + loadEmojis(); // reloads emojis.json + loadCharacters(); // reloads characters.json and accounts.json + return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk."); +} \ No newline at end of file diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index c1050be..8981b3c 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -1,7 +1,9 @@ import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; -import { cfg } from "../../systems/config"; -import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { getCurrentWeek, getWeekKey, getBringer } from "@systems/wrank"; +import { getEmoji } from "@systems/emojis"; +import { replyAndDelete } from "@utils"; +import { format } from "@src/systems/format"; export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { const week = getCurrentWeek(); @@ -11,29 +13,53 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): const formatNation = (nation: "capella" | "procyon"): string => { const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); if (entries.length === 0) return "—"; + const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon"); + return entries.map((e) => { - const isDone = e.tgCount >= goal; - const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0; - const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : ""; + const isDone = e.tgCount >= goal; + + // ── Character indicator ─────────────────────────────────────────────────── + const charStr = format.char({ class: e.class, level: 79, name: e.characterName }); + + // ── Rank indicator ─────────────────────────────────────────────────── + const rankStr = format.wrank.rank(e, goal); + const deltaStr = format.wrank.delta(e, { brackets: false }); + + // ── Bringer label ──────────────────────────────────────────────────── const bringerStr = bringer === e.userKey && isDone ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""; - return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; - }).join("\n"); + + // ── Score indicator ─────────────────────────────────────────────────── + const scoreStr = format.score(e.weeklyPoints); + + return `${rankStr}(${deltaStr}) ${charStr} — ${scoreStr} ${bringerStr}`; + }).join("\n"); + // return `${rankStr}(${deltaStr}) ${e.characterName} — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; + // }).join("\n"); }; const embed = new EmbedBuilder() - .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`) + .setTitle(`⚔️ TG Leaderboard — ${weekKey}`) .setColor(0xe8a317) .addFields( - { name: "🔵 Capella", value: formatNation("capella"), inline: true }, - { name: "🔴 Procyon", value: formatNation("procyon"), inline: true }, + { name: format.nation("Capella"), value: formatNation("capella"), inline: true }, + { name: format.nation("Procyon"), value: formatNation("procyon"), inline: true }, ) .setTimestamp(); + // const embed = new EmbedBuilder() + // .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`) + // .setColor(0xe8a317) + // .addFields( + // { name: "🔵 Capella", value: formatNation("capella"), inline: true }, + // { name: "🔴 Procyon", value: formatNation("procyon"), inline: true }, + // ) + // .setTimestamp(); + const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); const channel = await interaction.client.channels.fetch(channelId) as TextChannel; await channel.send({ embeds: [embed] }); return void replyAndDelete(interaction, "✅ Leaderboard posted."); -} +} \ No newline at end of file diff --git a/src/subcommands/rank/post.ts.bak b/src/subcommands/rank/post.ts.bak new file mode 100644 index 0000000..c1050be --- /dev/null +++ b/src/subcommands/rank/post.ts.bak @@ -0,0 +1,39 @@ +import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; +import { cfg } from "../../systems/config"; +import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; +import { replyAndDelete } from "../../utils"; + +export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { + const week = getCurrentWeek(); + const goal = cfg("wRankGoal"); + const weekKey = getWeekKey(); + + const formatNation = (nation: "capella" | "procyon"): string => { + const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); + if (entries.length === 0) return "—"; + const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon"); + return entries.map((e) => { + const isDone = e.tgCount >= goal; + const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0; + const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : ""; + const bringerStr = bringer === e.userKey && isDone + ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` + : ""; + return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; + }).join("\n"); + }; + + const embed = new EmbedBuilder() + .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`) + .setColor(0xe8a317) + .addFields( + { name: "🔵 Capella", value: formatNation("capella"), inline: true }, + { name: "🔴 Procyon", value: formatNation("procyon"), inline: true }, + ) + .setTimestamp(); + + const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + await channel.send({ embeds: [embed] }); + return void replyAndDelete(interaction, "✅ Leaderboard posted."); +} diff --git a/src/subcommands/score/submitCore.ts b/src/subcommands/score/submitCore.ts new file mode 100644 index 0000000..78f6d21 --- /dev/null +++ b/src/subcommands/score/submitCore.ts @@ -0,0 +1,86 @@ +import { cfg } from "@systems/config"; +import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { format } from "@format"; +import { getEmoji } from "@systems/emojis"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ScoreSubmitInput { + userKey: string; + pts: number; + slot?: number | string | null; // number = already resolved, string = needs normalizing, null/undefined = auto-detect + k?: number; + d?: number; + atk?: number; + def?: number; + heal?: number; + submittedByOfficer?: boolean; +} + +export type ScoreSubmitResult = + | { ok: true; message: string } + | { ok: false; message: string }; + +// ─── Core ───────────────────────────────────────────────────────────────────── + +export namespace score { + /** + * Resolve, validate and persist a score submission for a given userKey. + * Used by both the slash command handler and the modal submit handler. + */ + export async function submitForUser(input: ScoreSubmitInput): Promise { + const { userKey, pts, k, d, atk, def, heal, submittedByOfficer = false } = input; + + const { char, borrowedFrom } = getEffectiveCharacter(userKey); + if (!char) { + return { ok: false, message: "❌ No active character found. Use `/tg char set-active` first." }; + } + + // Resolve slot + let slot: number | null = null; + if (typeof input.slot === "number") { + slot = input.slot; + } else if (typeof input.slot === "string") { + slot = normalizeSlot(input.slot); + if (slot === null) { + return { ok: false, message: `❌ Could not parse slot "${input.slot}".` }; + } + } else { + slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; + } + + await submitScore({ + userKey: borrowedFrom ?? userKey, + playedBy: borrowedFrom ? userKey : undefined, + characterName: char.name, + cls: char.class, + nation: char.nation, + pts, + k, + d, + slot, + atk, + def, + heal, + submittedByOfficer, + }); + + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; + const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : ""; + const statsNote = [ + atk !== undefined ? `ATK: ${atk}` : null, + def !== undefined ? `DEF: ${def}` : null, + heal !== undefined ? `HEAL: ${heal}` : null, + ].filter(Boolean).join(" · "); + + const charDisplay = format.char(char); + return { + ok: true, + message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + // message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + }; + } +} \ No newline at end of file diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts index 43a6de6..82fdb2b 100644 --- a/src/subcommands/switch.ts +++ b/src/subcommands/switch.ts @@ -13,6 +13,7 @@ import { polls, updatePollMessage } from "@systems/poll"; import { getClassEmoji } from "@systems/emojis"; import { replyAndDelete } from "@src/utils"; import { format } from "@format"; +import { buildCharSelectButtons } from "@systems/charSelect"; import fs from "fs"; import path from "path"; @@ -92,15 +93,28 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr const charDisplay = format.char(resolvedChar); const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name); if (isOwner) { - return void replyAndDelete(interaction, - `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character and use the reclaim button that appears.`, - true - ); + await interaction.reply({ + content: `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to a different one:`, + components: buildCharSelectButtons(userKey, { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: resolvedChar.name, + appendToCustomId: ":yes", + }), + ephemeral: true, + }); + return; } - return void replyAndDelete(interaction, - `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, - true - ); + const buttons = buildCharSelectButtons(userKey, { + customIdPrefix: `switch_after_reclaim:${userKey}`, + excludeCharName: resolvedChar.name, + appendToCustomId: `:${"yes"}`, + }); + await interaction.reply({ + content: `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + components: buttons, + ephemeral: true, + }); + return; } } } diff --git a/src/systems/charSelect.ts b/src/systems/charSelect.ts new file mode 100644 index 0000000..63ffe9a --- /dev/null +++ b/src/systems/charSelect.ts @@ -0,0 +1,100 @@ +import { + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, +} from "discord.js"; +import { getCharacters, getCharacterByName } from "@systems/characters"; +import { getClassEmoji } from "@systems/emojis"; +import { format } from "@format"; +import { Character } from "@types"; +import fs from "fs"; +import path from "path"; + +const CHARS_PATH = path.join(__dirname, "../../data/characters.json"); + +export interface CharSelectOptions { + customIdPrefix: string; // e.g. "switch_after_reclaim:flash" + excludeCharName?: string; // exclude this char from the list + appendToCustomId?: string; // appended after charName e.g. ":yes" + pageSize?: number; // default 4 + page?: number; // default 0 +} + +/** + * Builds paginated character selection button rows for a given user. + * Includes own characters + shared characters. + * Returns up to 2 rows: one for char buttons, one for pagination if needed. + */ +export function buildCharSelectButtons( + userKey: string, + options: CharSelectOptions +): ActionRowBuilder[] { + const { + customIdPrefix, + excludeCharName, + appendToCustomId = "", + pageSize = 4, + page = 0, + } = options; + + // Gather own + shared chars + const ownChars = getCharacters(userKey); + + const sharedChars: Character[] = []; + try { + const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); + for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { + if (ownerKey === userKey) continue; + for (const c of data.characters ?? []) { + if (c.sharedWith?.includes(userKey)) sharedChars.push(c); + } + } + } catch {} + + const allChars = [...ownChars, ...sharedChars] + .filter((c) => c.name !== excludeCharName); + + const pageChars = allChars.slice(page * pageSize, (page + 1) * pageSize); + const hasNext = allChars.length > (page + 1) * pageSize; + const hasPrev = page > 0; + const rows: ActionRowBuilder[] = []; + + // Char buttons + if (pageChars.length > 0) { + const btns = pageChars.map((c) => { + const emojiStr = getClassEmoji(c.class); + const emoji = format.emoji(emojiStr); + const btn = new ButtonBuilder() + .setCustomId(`${customIdPrefix}:${c.name}${appendToCustomId}`) + .setStyle(ButtonStyle.Secondary) + .setLabel(`${c.level} ${c.name}`); + if (emoji) btn.setEmoji(emoji as any); + return btn; + }); + rows.push(new ActionRowBuilder().addComponents(...btns)); + } + + // Pagination row + const navBtns: ButtonBuilder[] = []; + if (hasPrev) { + navBtns.push( + new ButtonBuilder() + .setCustomId(`${customIdPrefix}_page:${page - 1}`) + .setLabel("← Prev") + .setStyle(ButtonStyle.Primary) + ); + } + if (hasNext) { + navBtns.push( + new ButtonBuilder() + .setCustomId(`${customIdPrefix}_page:${page + 1}`) + .setLabel("Next →") + .setStyle(ButtonStyle.Primary) + ); + } + if (navBtns.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...navBtns)); + } + + return rows; +} \ No newline at end of file diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts index 97f513b..608bee2 100644 --- a/src/systems/conflict.ts +++ b/src/systems/conflict.ts @@ -15,6 +15,8 @@ import { resolveMessage, nowFormatted } from "@systems/messages"; import { getClassEmoji } from "@systems/emojis"; import { format } from "@systems/format"; import { Character } from "@types"; +import { buildCharSelectButtons } from "@systems/charSelect"; + // ─── Config ─────────────────────────────────────────────────────────────────── const RECLAIM_STYLE = ButtonStyle.Secondary; @@ -122,6 +124,7 @@ export async function showConflictEmbed( } export async function handleConflictButton(interaction: ButtonInteraction): Promise { + console.log("[conflict] button received:", interaction.customId); const { customId } = interaction; // ── Pagination ────────────────────────────────────────────────────────────── @@ -196,6 +199,7 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom // ── Reclaim ───────────────────────────────────────────────────────────────── if (customId.startsWith("conflict_reclaim:")) { + console.log("[reclaim] handler triggered"); const parts = customId.split(":"); const ownerKey = parts[1]; const borrowerKey = parts[2]; @@ -266,30 +270,24 @@ export async function handleConflictButton(interaction: ButtonInteraction): Prom const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot!); + console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER); + // Notify borrower if enabled and we have their Discord ID if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) { try { const borrowerMember = await guild.members.fetch(borrowerDiscordId); - const borrowerChars = getCharacters(borrowerKey); - if (borrowerChars.length > 0) { - const btns = borrowerChars.slice(0, 5).map((c) => - applyCharToButton( - new ButtonBuilder() - .setCustomId(`switch_after_reclaim:${borrowerKey}:${c.name}:${borrowerVoteType}`) - .setStyle(ButtonStyle.Secondary), - c - ) - ); - await borrowerMember.send({ - content: `⚠️ **${format.char({ class: char?.class ?? "FB" as any, level: char?.level ?? 0, name: charName })}** was reclaimed by **${ownerKey}**. Pick another character:`, - components: [new ActionRowBuilder().addComponents(...btns)], - }); - } else { - await borrowerMember.send({ - content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. You've been removed from the poll.`, - }); - } + const btns = buildCharSelectButtons(borrowerKey, { + customIdPrefix: `switch_after_reclaim:${borrowerKey}`, + excludeCharName: charName, + appendToCustomId: `:${borrowerVoteType}`, + }); + console.log("[reclaim notify] btns length:", btns.length); + console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON()))); + await borrowerMember.send({ + content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`, + components: btns.length > 0 ? btns : [], + }); } catch { // DM may be disabled — silently ignore } diff --git a/src/systems/format.ts b/src/systems/format.ts index 899e0b0..7205b43 100644 --- a/src/systems/format.ts +++ b/src/systems/format.ts @@ -1,4 +1,4 @@ -import { ClassKey, Nation } from "@src/types"; +import { ClassKey, Nation, WRankEntry } from "@src/types"; import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis"; // ─── Individual formatters ──────────────────────────────────────────────────── @@ -55,6 +55,57 @@ function emoji(emojiStr: string): { name: string; id: string } | string | null { return emojiStr; } +// ─── W.Rank formatters ──────────────────────────────────────────────────────── + +export interface WRankDisplayOptions { + goal: number; + brackets?: boolean; // wrap delta in parentheses (default: true) +} + +/** + * Format the rank indicator for a wrank entry. + * Output: <:wrank_1_gold:> or <:wrank_1:> or bold/plain number fallback + */ +function wrankRank(entry: WRankEntry, goal: number): string { + const isDone = entry.tgCount >= goal; + const rankKey = isDone ? `wrank_${entry.currentRank}_gold` : `wrank_${entry.currentRank}`; + return getEmoji(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`); +} + +/** + * Format the delta indicator for a wrank entry. + * Output: <:wrank_up:><:wrank_up_2:> or ↑2, empty string if no change + */ +function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean }): string { + const brackets = options?.brackets ?? true; + const prev = entry.previousRank; + const delta = prev !== undefined ? entry.currentRank - prev : 0; + + if (delta === 0 && prev === undefined) return ""; + + let inner: string; + if (delta < 0) { + const abs = Math.abs(delta); + const numEmoji = getEmoji(`wrank_up_${abs}`); + inner = (getEmoji("wrank_up") || "↑") + (numEmoji || abs); + } else if (delta > 0) { + const numEmoji = getEmoji(`wrank_down_${delta}`); + inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta); + } else { + inner = (getEmoji("wrank_neutral") || "·") + "0"; + } + + return brackets ? ` (${inner})` : ` ${inner}`; +} + +/** + * Format a full wrank display string: rank + delta. + * Output: <:wrank_1_gold:> (<:wrank_up:><:wrank_up_2:>) + */ +function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string { + return wrankRank(entry, options.goal) + wrankDelta(entry, { brackets: options.brackets }); +} + // ─── Namespace export ───────────────────────────────────────────────────────── export const format = { @@ -62,4 +113,9 @@ export const format = { nation, score, emoji, + wrank: { + rank: wrankRank, + delta: wrankDelta, + full: wrankFull, + }, }; \ No newline at end of file diff --git a/src/systems/poll.ts b/src/systems/poll.ts index 239f629..efab526 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -6,13 +6,17 @@ import { TextChannel, GuildMember, } from "discord.js"; -import { PollState, VoteEntry, Nation, TGSlot } from "../types"; -import { cfg } from "./config"; -import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis"; -import { getActiveCharacter, getCharacterByName } from "./characters"; -import { resolveNation } from "./nations"; -import { getEntry, getBringer } from "./wrank"; -import { nowFormatted } from "./messages"; +import { PollState, VoteEntry, Nation, TGSlot } from "@src/types"; +import { cfg } from "@systems/config"; +import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis"; +import { getActiveCharacter, getCharacterByName } from "@systems/characters"; +import { resolveNation } from "@systems/nations"; +import { getEntry, getBringer } from "@systems/wrank"; +import { nowFormatted } from "@systems/messages"; +import { format } from "@format"; +import { persist } from "@systems/pollPersistence" +import { clearSessionBorrows } from "@systems/borrow"; +import { clearAllImpersonations } from "@systems/impersonate"; // ─── Poll state ─────────────────────────────────────────────────────────────── export const polls: Map = new Map(); @@ -51,30 +55,32 @@ export function resetPollOverrides(): void { ephemeralOverrides.clear(); } +export function lockPoll(slot: number): void { + const state = polls.get(slot); + if (!state) return; + state.locked = true; + + // Snapshot the userKeys that were in yes at lock time + state.lockedYesKeys = new Set( + [...state.yes.values()] + .map((e) => e.userKey) + .filter((k): k is string => !!k) + ); + + persist.save(polls) +} + + // ─── Character display ──────────────────────────────────────────────────────── function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { - const format = cfg("charDisplayFormat"); + const cfgFormat = cfg("charDisplayFormat"); const nation = entry.characterNation; const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; let wrank = ""; if (wRankEntry) { - const goal = cfg("wRankGoal"); - const isDone = wRankEntry.tgCount >= goal; - const rank = wRankEntry.currentRank; - const prev = wRankEntry.previousRank; - const delta = prev !== undefined ? rank - prev : 0; - // W.Rank emoji with text fallback - const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`; - const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`); - - // Delta arrows with text fallback - let deltaStr = ""; - if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`; - else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`; - else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`; - - wrank = `${rankStr}${deltaStr}`; + const wRankGoal = cfg("wRankGoal"); + wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true }); } const classStr = entry.characterClass @@ -85,7 +91,7 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { ? `${entry.characterLevel}` : ""; - let row = format + let row = cfgFormat .replace("{wrank}", wrank) .replace("{class}", classStr) .replace("{level}", levelStr) @@ -94,15 +100,33 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { .trim(); // Bringer title — independent of W.Rank so override always shows + function getNationBringerTitle(nation: Nation) { + const stormBringerIcon = getEmoji("storm_bringer") || "⚡"; + const stormBringer = `${stormBringerIcon}`; + + const luminousBringerIcon = getEmoji("luminous_bringer") || "⚡"; + const luminousBringer = `${luminousBringerIcon}` || `⚡ Luminous Bringer`; + + const nationMap = { + "Capella": luminousBringer, + "Procyon": stormBringer + }; + + return nationMap[nation]; + } if (nation && entry.userKey) { const bringer = getBringer(nation); if (bringer && bringer === entry.characterName) { - const emoji = nation === "Capella" - ? (getEmoji("luminous_bringer") || "🔆") - : (getEmoji("storm_bringer") || "⚡"); - const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; - row += ` · ${emoji} **${title}**`; + const bringerTitle = getNationBringerTitle(nation); + row += ` · ${bringerTitle}`; } + // if (bringer && bringer === entry.characterName) { + // const emoji = nation === "Capella" + // ? (getEmoji("luminous_bringer") || "🔆") + // : (getEmoji("storm_bringer") || "⚡"); + // const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; + // row += ` · ${emoji} **${title}**`; + // } } if (entry.borrowedFrom) { @@ -203,21 +227,41 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui return embed; } -export function buildButtons(disabled: boolean): ActionRowBuilder { +export function buildButtons( + disabled: boolean, + showSubmit?: boolean +): ActionRowBuilder[] { + if (showSubmit) { + const scoreEmoji = getEmoji("score"); + const submitBtn = new ButtonBuilder() + .setCustomId("tg_score_submit") + .setLabel("Submit Score") + .setStyle(ButtonStyle.Secondary); + if (scoreEmoji) submitBtn.setEmoji(format.emoji(scoreEmoji) ?? scoreEmoji); + return [new ActionRowBuilder().addComponents(submitBtn)]; + } + const yesBtn = new ButtonBuilder() .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled); const noBtn = new ButtonBuilder() .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled); - return new ActionRowBuilder().addComponents(yesBtn, noBtn); + return [new ActionRowBuilder().addComponents(yesBtn, noBtn)]; } -export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise { +export async function updatePollMessage( + channel: TextChannel, + slot: number, + overrideLockMsg?: string, + showSubmit?: boolean +): Promise { const state = polls.get(slot); if (!state?.messageId) return; + console.log(`[updatePollMessage] slot=${slot} showSubmit=${showSubmit} messageId=${state.messageId}`); + const buttons = buildButtons(state.locked || state.confirmed !== null, showSubmit); + console.log(`[updatePollMessage] components rows=${buttons.length}`); try { - const msg = await channel.messages.fetch(state.messageId); - const disabled = state.locked || state.confirmed !== null; - await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] }); + const msg = await channel.messages.fetch(state.messageId); + await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buttons }); } catch (err) { console.error("Failed to update poll message:", err); } @@ -225,8 +269,7 @@ export async function updatePollMessage(channel: TextChannel, slot: number, over export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { resetPollOverrides(); - const { clearSessionBorrows } = require("@systems/borrow"); - const { clearAllImpersonations } = require("@systems/impersonate"); + persist.clear(); clearSessionBorrows(); clearAllImpersonations(); @@ -237,9 +280,11 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise): SerializedPollState[] { + return [...polls.values()].map((s) => ({ + messageId: s.messageId, + slot: s.slot, + yes: [...s.yes.entries()], + no: [...s.no.entries()], + locked: s.locked, + confirmed: s.confirmed, + lockMessage: s.lockMessage, + confirmMessage: s.confirmMessage, + lockedYesKeys: s.lockedYesKeys ? [...s.lockedYesKeys] : undefined, + })); +} + +function deserialize(data: SerializedPollState[]): Map { + const polls = new Map(); + for (const s of data) { + polls.set(s.slot, { + messageId: s.messageId, + slot: s.slot, + yes: new Map(s.yes), + no: new Map(s.no), + locked: s.locked, + confirmed: s.confirmed, + lockMessage: s.lockMessage, + confirmMessage: s.confirmMessage, + lockedYesKeys: s.lockedYesKeys ? new Set(s.lockedYesKeys) : undefined, + }); + } + return polls; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export namespace persist { + export function save(polls: Map): void { + try { + fs.writeFileSync(PERSIST_PATH, JSON.stringify(serialize(polls), null, 2), "utf8"); + } catch (err) { + console.error("[pollPersistence] Failed to save poll state:", err); + } + } + + export function load(): Map | null { + try { + if (!fs.existsSync(PERSIST_PATH)) return null; + const raw = fs.readFileSync(PERSIST_PATH, "utf8"); + const data = JSON.parse(raw) as SerializedPollState[]; + const polls = deserialize(data); + console.log(`[pollPersistence] Restored ${polls.size} poll(s) from disk.`); + return polls; + } catch (err) { + console.error("[pollPersistence] Failed to load poll state:", err); + return null; + } + } + + export function clear(): void { + try { + if (fs.existsSync(PERSIST_PATH)) fs.unlinkSync(PERSIST_PATH); + } catch (err) { + console.error("[pollPersistence] Failed to clear poll state:", err); + } + } +} \ No newline at end of file diff --git a/src/systems/slots.ts b/src/systems/slots.ts index 00bf851..8eeb685 100644 --- a/src/systems/slots.ts +++ b/src/systems/slots.ts @@ -1,19 +1,21 @@ import cron from "node-cron"; -import { Client } from "discord.js"; +import { Client, TextChannel } from "discord.js"; import { cfg } from "./config"; import { TGSlot } from "../types"; +import { polls, updatePollMessage } from "@systems/poll"; -type PollCallback = (slot: TGSlot) => Promise; +type PollCallback = (slot: TGSlot) => Promise; type CloseCallback = (slot: TGSlot) => Promise; +type LockCallback = (slot: TGSlot) => Promise; let _scheduledTasks: cron.ScheduledTask[] = []; export function scheduleSlots( - client: Client, - onPollOpen: PollCallback, - onPollClose: CloseCallback + client: Client, + onPollOpen: PollCallback, + onPollLock: LockCallback, + onPollClose: CloseCallback, ): void { - // Clear existing schedules _scheduledTasks.forEach((t) => t.stop()); _scheduledTasks = []; @@ -21,37 +23,46 @@ export function scheduleSlots( const slots = cfg("slots").filter((s) => s.active); for (const slot of slots) { - // Parse poll open time + // Poll open const [openHour, openMin] = slot.pollOpens.split(":").map(Number); - - // Schedule poll open - const openTask = cron.schedule( + _scheduledTasks.push(cron.schedule( `${openMin} ${openHour} * * *`, () => onPollOpen(slot), { timezone: tz } - ); - _scheduledTasks.push(openTask); + )); - // Schedule poll close (tgHour + closesAfter minutes) + // Poll lock — exactly at tgHour (TG start, voting closes, lockedYesKeys snapshotted) + _scheduledTasks.push(cron.schedule( + `0 ${slot.tgHour} * * *`, + () => onPollLock(slot), + { timezone: tz } + )); + + // Poll close — tgHour + closesAfter minutes (TG end, Submit Score button appears) const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; const closeHour = Math.floor(closeMinTotal / 60) % 24; const closeMin = closeMinTotal % 60; - - const closeTask = cron.schedule( + _scheduledTasks.push(cron.schedule( `${closeMin} ${closeHour} * * *`, () => onPollClose(slot), { timezone: tz } - ); - _scheduledTasks.push(closeTask); + )); + + _scheduledTasks.push(cron.schedule("0 0 * * *", async () => { + const state = polls.get(slot.tgHour); + if (!state?.locked) return; // only if poll has been locked (TG happened) + const channel = await (client as any).channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot.tgHour, undefined, false); + console.log(`[${new Date().toISOString()}] Submit Score button removed.`); + }, { timezone: tz })); } // Weekly reset — Monday 00:00 - const resetTask = cron.schedule("0 0 * * 1", () => { + _scheduledTasks.push(cron.schedule("0 0 * * 1", () => { const { resetWeek } = require("./wrank"); resetWeek(); console.log("W.Rank weekly reset complete."); - }, { timezone: tz }); - _scheduledTasks.push(resetTask); + }, { timezone: tz })); console.log(`Scheduled ${slots.length} slot(s).`); -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 9f5b2e4..b33e6ba 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,6 +119,7 @@ export interface PollState { confirmed: "yes" | "no" | null; lockMessage?: string; confirmMessage?: string; + lockedYesKeys?: Set; // snapshot of userKeys in yes at lock time } // ─── Scores ──────────────────────────────────────────────────────────────────