From a4b4b5ae8e745514ec32ab245ae9918e657a86d2 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Wed, 3 Jun 2026 01:51:26 +0100 Subject: [PATCH 1/3] various features done, bug fixes on char conflicts --- .env | 20 +- .gitignore | 40 ++++ Dockerfile | 2 +- data/characters.json | 12 +- data/tg-history/2026-06-01-20.json | 22 +- data/tg-history/2026-06-01-22.json | 4 +- data/wrank.json | 25 ++- docker-compose.yml | 21 +- emoji-uploads/bl.png | Bin 0 -> 1503 bytes emoji-uploads/borrowed.png | Bin 0 -> 2379 bytes emoji-uploads/capella.png | Bin 0 -> 11569 bytes emoji-uploads/dm.png | Bin 0 -> 2262 bytes emoji-uploads/fa.png | Bin 0 -> 3057 bytes emoji-uploads/fb.png | Bin 0 -> 3786 bytes emoji-uploads/fg.png | Bin 0 -> 3378 bytes emoji-uploads/fs.png | Bin 0 -> 2078 bytes emoji-uploads/gl.png | Bin 0 -> 1576 bytes emoji-uploads/kd.png | Bin 0 -> 2532 bytes emoji-uploads/procyon.png | Bin 0 -> 17415 bytes emoji-uploads/rank.png | Bin 0 -> 2786 bytes emoji-uploads/score.png | Bin 0 -> 2277 bytes emoji-uploads/wa.png | Bin 0 -> 1574 bytes emoji-uploads/wi.png | Bin 0 -> 3509 bytes messages/emojis.json | 32 +-- nodemon.json | 4 +- package.json | 10 +- scripts/generate-aliases.ts | 76 +++++++ scripts/upload-emojis.ts | 110 ++++++++++ src/commands/tg.ts | 123 ++++++++---- src/handlers/autocomplete.ts | 104 ++++++++++ src/handlers/buttons.ts | 178 ++++++++++++----- src/handlers/interactions.ts | 128 +++++++++++- src/index.ts | 5 + src/subcommands/bringer/clear.ts | 8 +- src/subcommands/bringer/set.ts | 16 +- src/subcommands/char/accept.ts | 2 +- src/subcommands/char/active.ts | 29 +++ src/subcommands/char/add.ts | 10 +- src/subcommands/char/borrow.ts | 4 +- src/subcommands/char/remove.ts | 10 +- src/subcommands/char/setActive.ts | 20 +- src/subcommands/char/setNation.ts | 12 +- src/subcommands/char/setStats.ts | 12 +- src/subcommands/char/share.ts | 10 +- src/subcommands/impersonate.ts | 126 ++++++++++++ src/subcommands/poll/inject.ts | 38 ++-- src/subcommands/poll/seed.ts | 8 +- src/subcommands/rank/get.ts | 14 +- src/subcommands/rank/post.ts | 2 +- src/subcommands/score/get.ts | 47 +++-- src/subcommands/score/set.ts | 74 ++++--- src/subcommands/switch.ts | 116 +++++++---- src/systems/borrow.ts | 86 +++++--- src/systems/characters.ts | 62 +++--- src/systems/config.ts | 1 + src/systems/conflict.ts | 311 +++++++++++++++++++++++++++++ src/systems/format.ts | 65 ++++++ src/systems/history.ts | 2 +- src/systems/impersonate.ts | 44 ++++ src/systems/nations.ts | 6 +- src/systems/poll.ts | 22 +- src/systems/scores.ts | 12 +- src/systems/users.ts | 23 ++- src/systems/wrank.ts | 31 +-- src/types.ts | 24 ++- src/utils.ts | 60 +++++- tsconfig.json | 21 +- 67 files changed, 1810 insertions(+), 434 deletions(-) create mode 100644 .gitignore create mode 100644 emoji-uploads/bl.png create mode 100644 emoji-uploads/borrowed.png create mode 100644 emoji-uploads/capella.png create mode 100644 emoji-uploads/dm.png create mode 100644 emoji-uploads/fa.png create mode 100644 emoji-uploads/fb.png create mode 100644 emoji-uploads/fg.png create mode 100644 emoji-uploads/fs.png create mode 100644 emoji-uploads/gl.png create mode 100644 emoji-uploads/kd.png create mode 100644 emoji-uploads/procyon.png create mode 100644 emoji-uploads/rank.png create mode 100644 emoji-uploads/score.png create mode 100644 emoji-uploads/wa.png create mode 100644 emoji-uploads/wi.png create mode 100644 scripts/generate-aliases.ts create mode 100644 scripts/upload-emojis.ts create mode 100644 src/handlers/autocomplete.ts create mode 100644 src/subcommands/char/active.ts create mode 100644 src/subcommands/impersonate.ts create mode 100644 src/systems/conflict.ts create mode 100644 src/systems/format.ts create mode 100644 src/systems/impersonate.ts diff --git a/.env b/.env index cfaf247..8705b81 100644 --- a/.env +++ b/.env @@ -1,7 +1,13 @@ -DISCORD_TOKEN=MTUxMDc3NjgwNDE4NzU3NDMwMw.GgKLwR.eWiKvwpr03kVlUGquWxMzlOPC9h4Y_zvMh7KJM -POLL_CHANNEL_ID=1510761562997133333 -RESULTS_CHANNEL_ID=1510761595809304687 -SCORE_CHANNEL_ID=1510761785794232442 -CLIENT_ID=1510776804187574303 -GUILD_ID=402115662149058561 -EPHEMERAL_ENABLED=false \ No newline at end of file +DISCORD_TOKEN=MTUxMDk1OTgxNDYyMzEwNTA0NA.GNY7A9.Boq4MruKRqvo1UZ5JmsCkYN7q1xCTNKuqyh1oA +POLL_CHANNEL_ID=1511006387293917355 +RESULTS_CHANNEL_ID=1511006410627088544 +SCORE_CHANNEL_ID=1511006435079884991 +CLIENT_ID=1510959814623105044 +GUILD_ID=1511006171681652858 +EPHEMERAL_DELETE_MS=0 +POLL_EPHEMERAL_ENABLED=true # 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe35b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ + +# Environment variables — never commit these +.env + +# Runtime data — server-specific, never commit +data/config.json +data/characters.json +data/accounts.json +data/usermap.json +data/wrank.json +data/bringer.json +data/sessionPreferences.json +data/tg-history/ + +# Keep the data directory structure but not the contents +!data/.gitkeep +!data/tg-history/.gitkeep + +# Messages — user-specific files stay local +messages/users/ + +# Keep the users directory structure +!messages/users/.gitkeep + +# TypeScript build output +dist/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9e0ff56..1e689a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN npm install COPY src/ ./src/ COPY tsconfig.json ./ -CMD ["npx", "ts-node", "src/index.ts"] +CMD ["npx", "ts-node", "-r", "tsconfig-paths/register", "src/index.ts"] \ No newline at end of file diff --git a/data/characters.json b/data/characters.json index 01cd3b0..db2f30c 100644 --- a/data/characters.json +++ b/data/characters.json @@ -6,7 +6,7 @@ "class": "FB", "level": 79, "nation": "Procyon", - "active": false, + "active": true, "sharedWith": [ "invicjusz" ] @@ -16,7 +16,10 @@ "class": "WI", "level": 79, "nation": "Procyon", - "active": true + "active": false, + "sharedWith": [ + "invicjusz" + ] } ] }, @@ -38,7 +41,10 @@ "class": "BL", "level": 79, "nation": "Capella", - "active": true + "active": true, + "sharedWith": [ + "flash" + ] } ] }, diff --git a/data/tg-history/2026-06-01-20.json b/data/tg-history/2026-06-01-20.json index 0f614da..5f31433 100644 --- a/data/tg-history/2026-06-01-20.json +++ b/data/tg-history/2026-06-01-20.json @@ -14,17 +14,6 @@ } }, "scores": [ - { - "usermapKey": "flash", - "characterName": "»Flash«", - "class": "WI", - "nation": "Procyon", - "pts": 4000, - "submittedAt": "2026-06-01T03:18:24.563Z", - "slot": 20, - "date": "2026-06-01", - "submittedByOfficer": true - }, { "usermapKey": "invicjusz", "characterName": "ElementalEnchant", @@ -35,6 +24,17 @@ "slot": 20, "date": "2026-06-01", "submittedByOfficer": true + }, + { + "usermapKey": "flash", + "characterName": "«Flash»", + "class": "FB", + "nation": "Procyon", + "pts": 2000, + "submittedAt": "2026-06-01T22:07:39.907Z", + "slot": 20, + "date": "2026-06-01", + "submittedByOfficer": true } ] } \ No newline at end of file diff --git a/data/tg-history/2026-06-01-22.json b/data/tg-history/2026-06-01-22.json index d37c85a..746b79f 100644 --- a/data/tg-history/2026-06-01-22.json +++ b/data/tg-history/2026-06-01-22.json @@ -19,8 +19,8 @@ "characterName": "»Flash«", "class": "WI", "nation": "Procyon", - "pts": 2000, - "submittedAt": "2026-06-01T03:22:14.287Z", + "pts": 1000, + "submittedAt": "2026-06-01T22:05:28.186Z", "slot": 22, "date": "2026-06-01", "submittedByOfficer": false diff --git a/data/wrank.json b/data/wrank.json index 0c2404c..e03ed18 100644 --- a/data/wrank.json +++ b/data/wrank.json @@ -3,14 +3,31 @@ "weekKey": "2026-W23", "entries": { "capella": [], - "procyon": [] + "procyon": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "weeklyPoints": 1861.1111111111113, + "tgCount": 3, + "currentRank": 1, + "previousRank": 1 + } + ] + }, + "scoreIndex": { + "flash": [ + "2026-06-01-20", + "2026-06-01-22", + "2026-06-02-20" + ] }, - "scoreIndex": {}, "bringer": { "capella": null, "procyon": null, - "procyonOverride": "flash", - "capellaOverride": "zephyr" + "procyonOverride": "»Flash«", + "capellaOverride": "XefronYokuda" } } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1d8e859..4417708 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,17 @@ services: - tg-bot: + tg-bot-dev: build: - context: /opt/docker/tg-bot-ts - image: tg-bot-ts:latest - container_name: tg-bot-ts + context: /opt/docker/tg-bot-ts-dev + image: tg-bot-ts-dev:latest + container_name: tg-bot-ts-dev restart: unless-stopped env_file: - - /opt/docker/tg-bot-ts/.env + - /opt/docker/tg-bot-ts-dev/.env volumes: - - /opt/docker/tg-bot-ts/src:/app/src - - /opt/docker/tg-bot-ts/data:/app/data - - /opt/docker/tg-bot-ts/messages:/app/messages - - /opt/docker/tg-bot-ts/tsconfig.json:/app/tsconfig.json + - /opt/docker/tg-bot-ts-dev/src:/app/src + - /opt/docker/tg-bot-ts-dev/data:/app/data + - /opt/docker/tg-bot-ts-dev/scripts:/app/scripts + - /opt/docker/tg-bot-ts-dev/messages:/app/messages + - /opt/docker/tg-bot-ts-dev/emoji-uploads:/app/emoji-uploads + - /opt/docker/tg-bot-ts-dev/tsconfig.json:/app/tsconfig.json + - /opt/docker/tg-bot-ts-dev/data/sessionPreferences.json:/app/data/sessionPreferences.json diff --git a/emoji-uploads/bl.png b/emoji-uploads/bl.png new file mode 100644 index 0000000000000000000000000000000000000000..ec5bff1398f832a1df090d0dc13b7427df6586a1 GIT binary patch literal 1503 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 literal 0 HcmV?d00001 diff --git a/emoji-uploads/capella.png b/emoji-uploads/capella.png new file mode 100644 index 0000000000000000000000000000000000000000..dc4e72c791687d427cb3ec280d53387946912efa GIT binary patch literal 11569 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 C-W>@5 literal 0 HcmV?d00001 diff --git a/emoji-uploads/dm.png b/emoji-uploads/dm.png new file mode 100644 index 0000000000000000000000000000000000000000..c916547f79b4d6975106f2b47af29cabf017771f GIT binary patch literal 2262 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 literal 0 HcmV?d00001 diff --git a/emoji-uploads/fb.png b/emoji-uploads/fb.png new file mode 100644 index 0000000000000000000000000000000000000000..eb5f913e4f0b7750500f66152efc084ec394219a GIT binary patch literal 3786 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 GBmNJzF)pG2 literal 0 HcmV?d00001 diff --git a/emoji-uploads/fg.png b/emoji-uploads/fg.png new file mode 100644 index 0000000000000000000000000000000000000000..2f5bd0c4641b3ff9a026112d41865971849a83d9 GIT binary patch literal 3378 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 IM6N<$g2DXg`~Uy| literal 0 HcmV?d00001 diff --git a/emoji-uploads/gl.png b/emoji-uploads/gl.png new file mode 100644 index 0000000000000000000000000000000000000000..2c64ca6b88c6faa74d102f93e2a458c3764227b6 GIT binary patch literal 1576 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 literal 0 HcmV?d00001 diff --git a/emoji-uploads/rank.png b/emoji-uploads/rank.png new file mode 100644 index 0000000000000000000000000000000000000000..cf2a5302390b9093641dc1d48a933dc978c2e320 GIT binary patch literal 2786 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 literal 0 HcmV?d00001 diff --git a/emoji-uploads/score.png b/emoji-uploads/score.png new file mode 100644 index 0000000000000000000000000000000000000000..58731b22ba168182327a42e7aee202835602543a GIT binary patch literal 2277 zcmV(_`g8%^e{{R4h=>PzAFaQARU;qF*m;eA5Z<1fd zMgRZ^Ye_^wRCwC#n0t&=^%ciIzx$Xw@7ZN$cL&&=Wfu^6SQe;YwY2tOO^YCfD5SAnii+k^ncZ}5JEXENnW)K-LnD}LKr{<@cRv2Dyrf+ zwks;OBLGE!Do^pcRVWpx0Hc5f*kc>93Me&I5visGx%{vk8ZLh*jd)@0PQ?%SRRzcMIDb|n$(Gr8u8ZT^h-wmD&&73Y6h%XNCI|K&Bztm{ z#fzG_;oINjuYcRlrhn~h1^TKFcy?Vcgs27n1guC+jcfDfG?Go{80^b48I`hWbMYn9 zx%RF*k$Q|=|8dIYA-q#*1}V^VA2pK#tbO(+UR~dg<3eIu3=xX5_u#-_rDD&QQucn- z8XW~z0sY;5`9ybLKH3n}m!u4}ZrHSFnG)i<+ioIUKb=hXVI0RqHv$N$fOL^UCEid^ z$G)BHcxxYy3qeCATXbXH9o=<+0pve&Gf62eA;el>U9~CHqUromD&iI?9j>|kI+_# zp>2BdzIRKN?_YS)biRDe4=_y=$2JLuqlCjX_M7Vh~gu zifZtE=NsE#=UkpNpjei?(qYB^k{z^e8p$5Ubu0|SkM0X0aIsC3Xgto*gZp`H<2GV` zm5`JW57T#I_ycJhkNu8U@I5JIMM~MTdHb$g{`}Ochb0j)Fc6Bxpc28V*sqO|vI!G>Z){1#VzCFCrZ#@}@-MW-tkD9hA_zt5iPhAz z`spY5`^I*f!v;7OBJ^V zJ?)!Wweo3V(!=k0$e5o2OKUeR>vMnum~y<+kd*R_uQ9qSs?s6=(VR+s_J!x3l7&o{ zJhFF-ymIM6nF9G$U6GfDg#1F=bm`Oetw0nAkLNevzqGUTcchfon74Ocb;Z{X>Rxg7 zhL_fpO^=Y&6g=C*m#n38u)M2c+c#8gb>ipIs;s!x?|GUc^qGy5Xl{tBjj2>Fo6d!@ znF8@(2yGxwK9?o1XeO`k?!4A^oENLrV}Bf=ij>k5LKt<4nuhC_E$kY~GI<_J!dFm1gw(l9Re(~_ydtKLiQc5{I(V)J`$;A1u zu72c~y&YRu4ylc&_9>lz~?Wx9{| zV)*?0@Td2a?eAdM>%XI^X^Q>8W1EdP+7HXBGsy|j(Tgse8(c7Va;m4Ri^X5Lo&Epb z!S_~NjTkAB&{a}35n2*8Ob!}^tO|QJy~)kXuOK#Q3W?O`$z`(oZPzch6h&DM1SZUx zZoKXr>-)RjYtuqa=(@pAZn*=!WKurf$064xf20Q?pjaHHcw&%BI!AV7l*b=>fIIHJ zj}6alp`|&hFJ91cLHn+wZ;r>E#s#`$;f%nMceg9?)wjjtgQD zj+IS3&!g7$C^`;tSMti!YqLeQ^pLW1hShybYwf>JVUdl-(Skk3(D7s9e^A|btU+QIMKf>x{&(sjwD z`-s+t>8s9aswOZr1tGwz_OT+s^#no{D2_z;L20Cbp=y|>T?9%K_Sa`KIiNysPbV|x zrEpJa7Ez(njLv1j1ofJaR@Kj!9D^EL5|#o~g?J*&VD1Ed!_TqalPAZU(Q$#ky|+jB zbxC@ln<-6ELh%?CpxGZ{UP~)==d}`#*E416RP_2fQc@#OacR430mYMP+)9a1SZC+H zzU8Ma-_9-QntvR+GZyg|eX33O@uMue`4TEVjg#3yG6NZ;p)q?#3(0t#%*g`9VhN)u zPTR%Jbar-7mk4pJtH`0wOm@8U=y;u}?w(sO>TR9s_w7BDr@qdQp&4vhy`Pi4g%6s` z1#AJ+7d3I=<YOC&OhxicHs@J0vEtlPg-O1X8Sy*>&=C)EV* z{n_nH?m~DR?<-OoH3=9d0mDUACAOn5TGnt}KlO$-aBHD8~#(ujl&N+pZlbeX+}vXT8qa;lUv1{45SN?GD8 z;+(9;$Bu08zdn788}C^6=??!>&`&`pDE>15{GU(50s0w(00000NkvXXu0mjfcb`;I literal 0 HcmV?d00001 diff --git a/emoji-uploads/wa.png b/emoji-uploads/wa.png new file mode 100644 index 0000000000000000000000000000000000000000..f74e45ea70f1a2e49f722ac0653e6fb424e2cec6 GIT binary patch literal 1574 zcmV+>2HE+EP)(_`g8%^e{{R4h=>PzAFaQARU;qF*m;eA5Z<1fd zMgRZ>tVu*cRCwC#nO|sAR~*Mr%uTL}(IJ+yAlvLpDOzC@wwJL{#=wWlRJJyb1=rKB#Fbdcf0TBQF5{j+OJQ^-|j9~!Vx(pCvcYe&6-T5Z?X#H3ztevbCH z7jLT+jh*)&rj?1`mf%~7|qJc+NV~lHgr;dVA%uj(;r^XFcV4e!C=UZ#bO8m ze1Cuc6+|TCbXIdpw@sEfxzdEG!@ZU^E&<0Kk>T zWLiPZX7h&VNahv zjfq480f6T?&Y4!wW5XT)1(fIc zegGJAI-Nht?Ow09NvXS&b1W7^qtS>IMPb?|lg(y32mt)Rz(AASe)HzdKa?P)iIWVe zR;!VsC=E^1sRz|+wd#?Pkxl>@%g@jMNV0PrcSQ-(<#J&l5Kx3vtJRw|8jU8Uv%b8% z{6IrPLz&m>Z4gBfA3l6IAo<*G_irmf(lm_|6BEmj7K;VDySvfla^dmg$3K1D_bVFf z>gtYg9On!MgT0cfk(M6WtZhO>!m_MG3G(XIt4Px{vMjp{dGzQ}4244SI357_NF)*n z1OnY0$2l)wzFfdC%;wipPjho~Q;|qyc41*5CW_+os(h_NDX=&-~?6flsVmMvIXT8c8&gcTJP-@9C{pGQYWZ%9i}Z*Onyn+Tv+23)*&QNJ2w zad9zbdV2Dm2kq$Sn7?!9&W%*ptJ*NXZ*p^URT~0SrBY>V1knEn Y05@S}*Ag^5?EnA(07*qoM6N<$f-r9RQ2+n{ literal 0 HcmV?d00001 diff --git a/emoji-uploads/wi.png b/emoji-uploads/wi.png new file mode 100644 index 0000000000000000000000000000000000000000..6b424acfd4e07ac0cf5b80588e00b01d57c1fa38 GIT binary patch literal 3509 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 literal 0 HcmV?d00001 diff --git a/messages/emojis.json b/messages/emojis.json index cf6404d..dc97d6a 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -1,15 +1,15 @@ { - "capella": "<:Capella:1477082112560726238>", - "procyon": "<:Procyon:1477082175181426738>", - "bl": "<:bl:1510827912767475742>", - "fb": "<:fb:1510825907374395452>", - "fs": "<:fs:1510828058112954501>", - "fa": "<:fa:1510823955034935326>", - "fg": "<:fg:1510822372373037207>", - "gl": "<:gl:1510826513484873909>", - "dm": "<:dm:1510820686971670538>", - "wi": "<:wi:1510805237047230464>", - "wa": "<:wa:1510827932376109218>", + "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": "", @@ -36,8 +36,8 @@ "wrank_9_gold": "", "wrank_10": "", "wrank_10_gold": "", - "kd": "", - "score": "", - "rank": "", - "borrowed": "🔗" -} + "kd": "<:kd:1511020939226124339>", + "score": "<:score:1511020950718513172>", + "rank": "<:rank:1511020947019137187>", + "borrowed": "<:borrowed:1511020906754085057>" +} \ No newline at end of file diff --git a/nodemon.json b/nodemon.json index 0b84199..c0af92e 100644 --- a/nodemon.json +++ b/nodemon.json @@ -2,5 +2,5 @@ "watch": ["src"], "ext": "ts", "ignore": ["src/**/*.spec.ts"], - "exec": "ts-node src/index.ts" -} + "exec": "ts-node -r tsconfig-paths/register src/index.ts" +} \ No newline at end of file diff --git a/package.json b/package.json index 9898fcd..e489686 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "description": "Cabal Online TG planning and tracking bot", "main": "src/index.ts", "scripts": { - "start": "ts-node src/index.ts", + "start": "ts-node -r tsconfig-paths/register src/index.ts", "dev": "nodemon", - "register": "ts-node src/index.ts --register" + "register": "ts-node -r tsconfig-paths/register src/index.ts --register", + "aliases": "ts-node scripts/generate-aliases.ts" }, "dependencies": { "discord.js": "^14.15.3", @@ -17,6 +18,7 @@ "@types/node-cron": "^3.0.0", "nodemon": "^3.1.0", "ts-node": "^10.9.2", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "tsconfig-paths": "^4.2.0" } -} +} \ No newline at end of file diff --git a/scripts/generate-aliases.ts b/scripts/generate-aliases.ts new file mode 100644 index 0000000..4b6620e --- /dev/null +++ b/scripts/generate-aliases.ts @@ -0,0 +1,76 @@ +/** + * Generates path aliases in tsconfig.json for every directory under src/ + * and optionally data/ and messages/. + * + * Usage: npx ts-node scripts/generate-aliases.ts + */ + + import fs from "fs"; + import path from "path"; + + const TSCONFIG_PATH = path.join(__dirname, "../tsconfig.json"); + const SRC_DIR = path.join(__dirname, "../src"); + + function getDirs(dir: string, prefix: string): Record { + const result: Record = {}; + if (!fs.existsSync(dir)) return result; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const relPath = path.join(prefix, entry.name); + const aliasKey = `@${entry.name}/*`; + const aliasVal = [`src/${relPath}/*`]; + result[aliasKey] = aliasVal; + + // Also add a direct alias for files in this dir (e.g. @utils → src/utils) + const directKey = `@${entry.name}`; + result[directKey] = [`src/${relPath}`]; + } + return result; + } + + // Also alias every .ts file directly under src/ (e.g. @utils → src/utils) + function getFiles(dir: string): Record { + const result: Record = {}; + if (!fs.existsSync(dir)) return result; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; + const name = path.basename(entry.name, ".ts"); + result[`@${name}`] = [`src/${name}`]; + } + return result; + } + + const tsconfig = JSON.parse(fs.readFileSync(TSCONFIG_PATH, "utf8")); + + // Generate aliases for all dirs under src/ + const newPaths: Record = { + "@src/*": ["src/*"], + ...getDirs(SRC_DIR, ""), + ...getFiles(SRC_DIR), + }; + + // Also scan subdirs (systems, subcommands, handlers, commands) + for (const subdir of ["systems", "subcommands", "handlers", "commands"]) { + const full = path.join(SRC_DIR, subdir); + if (!fs.existsSync(full)) continue; + for (const entry of fs.readdirSync(full, { withFileTypes: true })) { + const name = entry.isDirectory() ? entry.name : path.basename(entry.name, ".ts"); + if (!entry.isDirectory() && !entry.name.endsWith(".ts")) continue; + // Don't override parent alias + if (!newPaths[`@${name}`]) { + newPaths[`@${name}`] = entry.isDirectory() + ? [`src/${subdir}/${name}/*`] + : [`src/${subdir}/${name}`]; + } + if (entry.isDirectory() && !newPaths[`@${name}/*`]) { + newPaths[`@${name}/*`] = [`src/${subdir}/${name}/*`]; + } + } + } + + tsconfig.compilerOptions.paths = newPaths; + fs.writeFileSync(TSCONFIG_PATH, JSON.stringify(tsconfig, null, 2)); + + console.log(`✅ Generated ${Object.keys(newPaths).length} path aliases in tsconfig.json`); + console.log(Object.keys(newPaths).sort().join("\n")); \ No newline at end of file diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts new file mode 100644 index 0000000..b658158 --- /dev/null +++ b/scripts/upload-emojis.ts @@ -0,0 +1,110 @@ +/** + * 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 + * + * Automatically updates messages/emojis.json with the uploaded emoji IDs. + */ + + import { REST, Routes } from "discord.js"; + import fs from "fs"; + import path from "path"; + + // Load .env manually since we're outside the bot + 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(); + } + } + + const TOKEN = process.env.DISCORD_TOKEN!; + const GUILD_ID = process.env.GUILD_ID!; + + if (!TOKEN || !GUILD_ID) { + console.error("❌ DISCORD_TOKEN and GUILD_ID must be set in .env"); + process.exit(1); + } + + const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads"); + const emojisPath = path.join(__dirname, "../messages/emojis.json"); + + if (!fs.existsSync(emojiDir)) { + console.error(`❌ Emoji directory not found: ${emojiDir}`); + console.error(` Create it and place your emoji PNG files inside.`); + process.exit(1); + } + + const rest = new REST({ version: "10" }).setToken(TOKEN); + + async function uploadEmojis(): Promise { + const files = fs.readdirSync(emojiDir).filter((f) => + [".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase()) + ); + + if (files.length === 0) { + console.error("❌ No image files found in the emoji directory."); + process.exit(1); + } + + // Load existing emojis.json + let emojiMap: Record = {}; + try { + emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); + } catch { + console.warn("⚠️ Could not load emojis.json — will create fresh mapping."); + } + + console.log(`📁 Found ${files.length} file(s) in ${emojiDir}\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])); + + let uploaded = 0; + let skipped = 0; + let failed = 0; + + 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}`); + skipped++; + continue; + } + + try { + const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; + const result = await rest.post(Routes.guildEmojis(GUILD_ID), { + body: { name: emojiName, image: base64 }, + }) as any; + + const formatted = `<:${emojiName}:${result.id}>`; + emojiMap[emojiName] = formatted; + console.log(`✅ Uploaded: ${emojiName} → ${formatted}`); + uploaded++; + + // Avoid rate limiting + await new Promise((r) => setTimeout(r, 600)); + } catch (err: any) { + console.error(`❌ Failed: ${emojiName} — ${err.message}`); + failed++; + } + } + + fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); + + console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); + console.log(`💾 messages/emojis.json updated`); + } + + uploadEmojis().catch(console.error); \ No newline at end of file diff --git a/src/commands/tg.ts b/src/commands/tg.ts index a1efe26..67e60ad 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -18,6 +18,7 @@ import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEp import { handleInject, handleRemoveVote } from "../subcommands/poll/inject"; import { handleSeed } from "../subcommands/poll/seed"; import { handlePurge } from "../subcommands/poll/purge"; +import { handleImpersonate } from "../subcommands/impersonate"; // Char subcommands (borrow / sharing system) import { handleCharBorrow } from "../subcommands/char/borrow"; @@ -46,6 +47,14 @@ import { handleBringerClear } from "../subcommands/bringer/clear"; import { handleSwitch } from "../subcommands/switch"; import { handleHistory } from "../subcommands/history"; +// Import char handlers here to keep tg.ts clean +import { handleCharAdd } from "../subcommands/char/add"; +import { handleCharRemove } from "../subcommands/char/remove"; +import { handleCharSetActive } from "../subcommands/char/setActive"; +import { handleCharSetNation } from "../subcommands/char/setNation"; +import { handleCharSetStats } from "../subcommands/char/setStats"; +import { handleCharActive } from "../subcommands/char/active"; + export function buildTgCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() .setName("tg") @@ -56,41 +65,49 @@ export function buildTgCommand(): SlashCommandBuilder { .setName("poll") .setDescription("Manage the TG poll") .addSubcommand((s) => s.setName("start").setDescription("Post a fresh TG poll") - .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false))) + .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false)) + ) .addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll") - .addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false))) + .addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)) + ) .addSubcommand((s) => s.setName("unlock").setDescription("Unlock the active poll")) .addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening") .addStringOption((o) => o.setName("decision").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false)) - .addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").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("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) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user") - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))) .addSubcommand((s) => s.setName("remove-vote").setDescription("Remove a vote for a registered user") - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel")) .addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing")) ); @@ -102,10 +119,17 @@ export function buildTgCommand(): SlashCommandBuilder { .addSubcommand((s) => s.setName("set").setDescription("Submit a score") .addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true)) .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addIntegerOption((o) => o.setName("k").setDescription("Kills").setRequired(false)) + .addIntegerOption((o) => o.setName("d").setDescription("Deaths").setRequired(false)) + .addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false)) + .addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false)) + .addIntegerOption((o) => o.setName("heal").setDescription("Healing score (FA only)").setRequired(false)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("get").setDescription("View a score") .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) ); // ── rank group ───────────────────────────────────────────────────────────── @@ -113,7 +137,8 @@ export function buildTgCommand(): SlashCommandBuilder { .setName("rank") .setDescription("W.Rank management") .addSubcommand((s) => s.setName("get").setDescription("View W.Rank") - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)")) ); @@ -140,7 +165,8 @@ export function buildTgCommand(): SlashCommandBuilder { .addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))) @@ -148,8 +174,8 @@ export function buildTgCommand(): SlashCommandBuilder { // ── switch ───────────────────────────────────────────────────────────────── cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) ); // ── char group ───────────────────────────────────────────────────────────── @@ -173,40 +199,53 @@ export function buildTgCommand(): SlashCommandBuilder { .addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true)) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("remove").setDescription("Remove a character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-active").setDescription("Set active character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats") .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false)) .addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false)) .addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false)) .addIntegerOption((o) => o.setName("heal").setDescription("Healing score").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session") - .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true)) - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request") - .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request") - .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("share").setDescription("Permanently share a character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true)) - .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true)) - .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) + .addSubcommand((s) => s.setName("active").setDescription("Check active character for a user") + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer: check others)").setRequired(false).setAutocomplete(true)) + ) ); // ── history ──────────────────────────────────────────────────────────────── @@ -215,6 +254,9 @@ export function buildTgCommand(): SlashCommandBuilder { .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) ); + // ── impersonate ──────────────────────────────────────────────────────────────── + cmd.addSubcommand((s) => s.setName("impersonate").setDescription("Impersonate a registered user for testing (officer only)")); + return cmd; } @@ -284,14 +326,9 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction): if (sub === "decline") return handleCharDecline(interaction); if (sub === "share") return handleCharShare(interaction); if (sub === "unshare") return handleCharUnshare(interaction); + if (sub === "active") return handleCharActive(interaction); } - if (!group && sub === "switch") return handleSwitch(interaction); - if (!group && sub === "history") return handleHistory(interaction); + if (!group && sub === "switch") return handleSwitch(interaction); + if (!group && sub === "history") return handleHistory(interaction); + if (!group && sub === "impersonate") return handleImpersonate(interaction); } - -// Import char handlers here to keep tg.ts clean -import { handleCharAdd } from "../subcommands/char/add"; -import { handleCharRemove } from "../subcommands/char/remove"; -import { handleCharSetActive } from "../subcommands/char/setActive"; -import { handleCharSetNation } from "../subcommands/char/setNation"; -import { handleCharSetStats } from "../subcommands/char/setStats"; \ No newline at end of file diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts new file mode 100644 index 0000000..7e93bbf --- /dev/null +++ b/src/handlers/autocomplete.ts @@ -0,0 +1,104 @@ +import { AutocompleteInteraction } from "discord.js"; +import { resolveUser } from "@systems/users"; +import { getCharacters } from "@systems/characters"; +import { cfg } from "@systems/config"; +import { getNationEmoji } from "@systems/emojis"; +import fs from "fs"; +import path from "path"; + +// ─── Autocomplete subsets ───────────────────────────────────────────────────── + +async function autocompleteCharNames( + interaction: AutocompleteInteraction, + focused: string +): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const user = await resolveUser(member); + if (!user.userKey) return interaction.respond([]); + + const ownChars = getCharacters(user.userKey).map((c) => { + const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : ""; + return { + name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), + value: c.name, + }; + }); + + const sharedChars: { name: string; value: string }[] = []; + try { + const chars = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") + ); + for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { + if (ownerKey === user.userKey) continue; + for (const char of data.characters ?? []) { + if (char.sharedWith?.includes(user.userKey)) { + const nationEmoji = char.nation ? (getNationEmoji(char.nation) || char.nation) : ""; + sharedChars.push({ + name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(), + value: char.name, + }); + } + } + } + } catch {} + + const all = [...ownChars, ...sharedChars] + .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) + .slice(0, 25); + + await interaction.respond(all); +} + +async function autocompleteUserKeys( + interaction: AutocompleteInteraction, + focused: string +): Promise { + try { + const usermap = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") + ); + const choices = Object.entries(usermap) + .map(([, entry]: [string, any]) => { + const fileKey = typeof entry === "string" ? entry : entry.file; + const alias = typeof entry === "object" ? (entry.aliases?.[0] ?? fileKey) : fileKey; + return { name: `${alias} (${fileKey})`, value: fileKey }; + }) + .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) + .slice(0, 25); + await interaction.respond(choices); + } catch { + await interaction.respond([]); + } +} + +async function autocompleteSlots( + interaction: AutocompleteInteraction, + focused: string +): Promise { + const slots = cfg("slots") + .filter((s) => s.active) + .map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) })) + .filter((s) => s.name.includes(focused)); + await interaction.respond(slots); +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export async function handleAutocomplete(interaction: AutocompleteInteraction): Promise { + try { + const focused = interaction.options.getFocused(true); + const optionName = focused.name; + const focusedValue = focused.value as string; + + if (optionName === "char_name") return await autocompleteCharNames(interaction, focusedValue); + if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); + if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); + if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); + + await interaction.respond([]); + } catch (err) { + console.error("[autocomplete] error:", err); + try { await interaction.respond([]); } catch {} + } +} \ No newline at end of file diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index af44df7..9d607c9 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -1,21 +1,86 @@ import { ButtonInteraction, TextChannel } from "discord.js"; -import { cfg } from "../systems/config"; -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 { 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 { showConflictEmbed } from "@systems/conflict"; +import { getCharacters } from "@systems/characters"; +import { getImpersonation } from "@systems/impersonate"; +import { format } from "@format"; +import { Character } from "@src/types"; -const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false"; -const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); -const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); +const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); const clickCounts = new Map(); +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function isCharacterInPoll( + state: ReturnType, + charName: string, + excludeVoteId: string, + excludeUserKey: string = "" +): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } { + if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined }; + for (const [otherId, entry] of state.yes.entries()) { + if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) { + return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom }; + } + } + return { found: false, entryUserKey: null, borrowedFrom: undefined }; +} + +function isCharacterOwner(userKey: string | null, charName: string): boolean { + if (!userKey) return false; + return getCharacters(userKey).some((c) => c.name === charName); +} + +async function handleCharacterConflict( + interaction: ButtonInteraction, + userKey: string | null, + char: Character, + entryUserKey: string | null, + clicks: { yes: number; no: number }, + votedYes: boolean +): Promise { + // Decrement click since we're blocking this vote + if (votedYes) clicks.yes -= 1; + else clicks.no -= 1; + + const isOwner = isCharacterOwner(userKey, char.name); + + if (isOwner && userKey) { + const allChars = getCharacters(userKey); + const borrowedChar = allChars.find((c) => c.name === char.name); + if (borrowedChar && entryUserKey) { + await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars); + return true; + } + } + + 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 + }); + return true; +} + +// ─── Main button handler ────────────────────────────────────────────────────── + export async function handleButton(interaction: ButtonInteraction): Promise { if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; - // Defer immediately to avoid 3s timeout - await interaction.deferUpdate(); + try { + await interaction.deferUpdate(); + } catch { + return; + } const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; if (slot === undefined) return; @@ -24,61 +89,91 @@ export async function handleButton(interaction: ButtonInteraction): Promise 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); - } + const capella = format.nation("Capella"); + const procyon = format.nation("Procyon"); + await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true }); return; } // Click tracking - if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 }); - const clicks = clickCounts.get(userId)!; + if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 }); + const clicks = clickCounts.get(voteId)!; if (votedYes && clicks.yes >= LOCK_AT) return; if (!votedYes && clicks.no >= LOCK_AT) return; // Ignore same vote - if (votedYes && state.yes.has(userId)) return; - if (!votedYes && state.no.has(userId)) return; + if (votedYes && state.yes.has(voteId)) return; + if (!votedYes && state.no.has(voteId)) return; + // Increment click (may be decremented in conflict handler) if (votedYes) clicks.yes += 1; else clicks.no += 1; const clickCount = votedYes ? clicks.yes : clicks.no; - // Resolve messages — officer override takes priority - const publicMsg = getPublicOverride(userId, votedYes ? "yes" : "no") - ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname); + // Resolve messages + const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no") + ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); - const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no") - ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname); + const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no") + ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); - const baseEntry = createVoteEntry(userId, member, user.usermapKey, user.discordUsername); + const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); + // Character conflict check — applies to both Yes and No + if (baseEntry.characterName) { + const conflictChar = { + name: baseEntry.characterName!, + class: baseEntry.characterClass!, + level: baseEntry.characterLevel!, + nation: baseEntry.characterNation!, + active: false, // not needed for display + }; + + const { found, entryUserKey, borrowedFrom } = isCharacterInPoll( + state, baseEntry.characterName, voteId, user.userKey ?? "" + ); + if (found) { + await handleCharacterConflict( + interaction, user.userKey, conflictChar, + entryUserKey, clicks, votedYes + ); + return; + } + } + + // Register vote if (votedYes) { - const previousNo = state.no.get(userId); - state.no.delete(userId); - state.yes.set(userId, { + const previousNo = state.no.get(voteId); + state.no.delete(voteId); + state.yes.set(voteId, { ...baseEntry, + discordId: userId, votedAt: now, previousNoAt: previousNo?.votedAt, publicMessage: publicMsg ?? undefined, }); } else { - const previousYes = state.yes.get(userId); - state.yes.delete(userId); - state.no.set(userId, { + const previousYes = state.yes.get(voteId); + state.yes.delete(voteId); + state.no.set(voteId, { ...baseEntry, votedAt: now, + discordId: userId, previousYesAt: previousYes?.votedAt, publicMessage: publicMsg ?? undefined, }); @@ -87,18 +182,11 @@ export async function handleButton(interaction: ButtonInteraction): Promise= LOCK_AT; if (locked) state.locked = true; - // Send ephemeral follow-up (since we already deferred with deferUpdate) - if (EPHEMERAL_ENABLED) { - const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; - const content = ephemeralMsg - ? `${ephemeralMsg}${lockedSuffix}` - : locked ? "🔒 You've been locked in." : null; - - if (content) { - const reply = await interaction.followUp({ content, ephemeral: true }); - if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); - } - } + const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; + const msgContent = ephemeralMsg + ? `${ephemeralMsg}${lockedSuffix}` + : locked ? "🔒 You've been locked in." : null; + await pollReplyAndDelete(interaction, msgContent); const channel = interaction.channel as TextChannel; await updatePollMessage(channel, slot); diff --git a/src/handlers/interactions.ts b/src/handlers/interactions.ts index 5a2179d..ee01719 100644 --- a/src/handlers/interactions.ts +++ b/src/handlers/interactions.ts @@ -1,20 +1,134 @@ -import { Interaction, ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; -import { handleButton } from "./buttons"; -import { handleTgCommand } from "../commands/tg"; -import { handleTgConfigCommand } from "../commands/tgConfig"; -import { handleBorrowAcceptButton } from "../subcommands/char/accept"; -import { handleBorrowDeclineButton } from "../subcommands/char/decline"; +import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; +import { handleButton } from "@handlers/buttons"; +import { handleTgCommand } from "@commands/tg"; +import { handleTgConfigCommand } from "@commands/tgConfig"; +import { handleBorrowAcceptButton } from "@subcommands/char/accept"; +import { handleBorrowDeclineButton } from "@subcommands/char/decline"; +import { handleConflictButton } from "@systems/conflict"; +import { handleImpersonateButton } from "@subcommands/impersonate"; +import { handleAutocomplete } from "@handlers/autocomplete"; +import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; +import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow"; +import { polls, updatePollMessage } from "@systems/poll"; +import { cfg } from "@systems/config"; +import { resolveMessage, nowFormatted } from "@systems/messages"; +import { format } from "@format"; +import fs from "fs"; +import path from "path"; + +async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise { + const parts = btn.customId.split(":"); + const userKey = parts[1]; + const charName = parts[2]; + const prevVoteType = (parts[3] ?? "yes") as "yes" | "no"; + + const chars = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") + ); + + let resolvedChar: any = null; + let borrowedFrom: string | null = null; + + // Try own char first + const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName); + if (ownEntry) { + setActiveCharacter(userKey, charName); + clearSessionBorrowForUser(userKey); + resolvedChar = ownEntry; + } else { + // Try shared char + for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { + const char = data.characters?.find( + (c: any) => c.name === charName && c.sharedWith?.includes(userKey) + ); + if (char) { + setPersistentPreference(userKey, ownerKey, charName); + clearSessionBorrowForUser(userKey); + resolvedChar = char; + borrowedFrom = ownerKey; + break; + } + } + } + + if (!resolvedChar) { + await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true }); + return; + } + + // Re-add to poll with previous vote type + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + if (state && !state.locked && state.confirmed === null) { + const { char } = getEffectiveCharacter(userKey); + const now = nowFormatted(); + const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null); + const voteEntry = { + userKey, + displayName: charName, + characterName: char?.name ?? charName, + characterClass: char?.class ?? resolvedChar.class, + characterLevel: char?.level ?? resolvedChar.level, + characterNation: char?.nation ?? resolvedChar.nation, + borrowedFrom: borrowedFrom ?? undefined, + discordId: btn.user.id, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }; + + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + if (entry.userKey === userKey) { + state.yes.delete(id); + state.no.delete(id); + } + } + + if (prevVoteType === "yes") { + state.yes.set(`switch_reclaim:${userKey}`, voteEntry); + } else { + state.no.set(`switch_reclaim:${userKey}`, voteEntry); + } + + const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + } + + const charDisplay = resolvedChar ? format.char(resolvedChar) : charName; + const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + await btn.reply({ + content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`, + ephemeral: true, + }); +} export async function handleInteraction(interaction: Interaction): Promise { try { + if (interaction.isAutocomplete()) { + await handleAutocomplete(interaction); + return; + } + if (interaction.isButton()) { const btn = interaction as ButtonInteraction; - // Borrow accept/decline buttons from DM + if (btn.customId.startsWith("conflict_")) { + return await handleConflictButton(btn); + } + + if (btn.customId.startsWith("impersonate_")) { + return await handleImpersonateButton(btn); + } + + if (btn.customId.startsWith("switch_after_reclaim:")) { + return await handleSwitchAfterReclaim(btn); + } + if (btn.customId.startsWith("borrow_accept:")) { const [, ownerKey, requesterKey] = btn.customId.split(":"); return await handleBorrowAcceptButton(btn, ownerKey, requesterKey); } + if (btn.customId.startsWith("borrow_decline:")) { const [, ownerKey, requesterKey] = btn.customId.split(":"); return await handleBorrowDeclineButton(btn, ownerKey, requesterKey); diff --git a/src/index.ts b/src/index.ts index 50bb28b..fbae39c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,11 @@ client.once("clientReady", async () => { loadCharacters(); loadWRank(); + // Warm member cache + 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(); diff --git a/src/subcommands/bringer/clear.ts b/src/subcommands/bringer/clear.ts index caf04db..59f2d7a 100644 --- a/src/subcommands/bringer/clear.ts +++ b/src/subcommands/bringer/clear.ts @@ -1,10 +1,10 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { clearBringerOverride } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; -import { Nation } from "../../types"; +import { clearBringerOverride } from "@systems/wrank"; +import { replyAndDelete } from "@utils"; +import { Nation } from "@types"; export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise { const nation = interaction.options.getString("nation", true) as Nation; clearBringerOverride(nation); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); -} +} \ No newline at end of file diff --git a/src/subcommands/bringer/set.ts b/src/subcommands/bringer/set.ts index 5e81fd1..389aecc 100644 --- a/src/subcommands/bringer/set.ts +++ b/src/subcommands/bringer/set.ts @@ -1,12 +1,12 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { setBringerOverride } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; -import { Nation } from "../../types"; +import { setBringerOverride } from "@systems/wrank"; +import { replyAndDelete } from "@utils"; +import { Nation } from "@types"; export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise { - const nation = interaction.options.getString("nation", true) as Nation; - const usermapKey = interaction.options.getString("name", true); + const nation = interaction.options.getString("nation", true) as Nation; + const charName = interaction.options.getString("name", true); - setBringerOverride(nation, usermapKey); - return void replyAndDelete(interaction, `✅ **${usermapKey}** set as ${nation === "Capella" ? "Luminous" : "Storm"} Bringer for this week.`); -} + setBringerOverride(nation, charName); + return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`); +} \ No newline at end of file diff --git a/src/subcommands/char/accept.ts b/src/subcommands/char/accept.ts index 22dfb6f..57c32f0 100644 --- a/src/subcommands/char/accept.ts +++ b/src/subcommands/char/accept.ts @@ -48,7 +48,7 @@ async function acceptBorrow( const state = polls.get(slot)!; for (const map of [state.yes, state.no]) { for (const [, entry] of map) { - if (entry.usermapKey === requesterKey) { + if (entry.userKey === requesterKey) { entry.characterName = char.name; entry.characterClass = char.class; entry.characterLevel = char.level; diff --git a/src/subcommands/char/active.ts b/src/subcommands/char/active.ts new file mode 100644 index 0000000..4550470 --- /dev/null +++ b/src/subcommands/char/active.ts @@ -0,0 +1,29 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; +import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; + +export async function handleCharActive(interaction: ChatInputCommandInteraction): Promise { + const nameArg = interaction.options.getString("name"); + const group = interaction.options.getSubcommandGroup(false); + const sub = interaction.options.getSubcommand(); + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + if (nameArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can check other players' active character."); + } + const targetKey = nameArg ?? (await resolveUser(member)).userKey; + if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const { getEffectiveCharacter } = require("../../systems/borrow"); + const { char, borrowedFrom } = getEffectiveCharacter(targetKey); + if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`); + + const { getClassEmoji } = require("../../systems/emojis"); + const classEmoji = getClassEmoji(char.class) || char.class; + const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + return void replyAndDelete(interaction, `${classEmoji} ${char.level} ${char.name}${borrowed}`); +} \ No newline at end of file diff --git a/src/subcommands/char/add.ts b/src/subcommands/char/add.ts index d7c71dc..d9bb741 100644 --- a/src/subcommands/char/add.ts +++ b/src/subcommands/char/add.ts @@ -14,18 +14,18 @@ export async function handleCharAdd(interaction: ChatInputCommandInteraction): P const level = interaction.options.getInteger("level", true); const nation = interaction.options.getString("nation", true) as Nation; - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const added = addCharacter(usermapKey, { name: charName, class: cls, level, nation }); + const added = addCharacter(userKey, { name: charName, class: cls, level, nation }); if (!added) return replyAndDelete(interaction, `❌ A character named **${charName}** already exists.`); return replyAndDelete(interaction, `✅ Character **«${charName}»** (${cls} · Lv${level} · ${nation}) added.`); diff --git a/src/subcommands/char/borrow.ts b/src/subcommands/char/borrow.ts index f0a00a5..2cf0f39 100644 --- a/src/subcommands/char/borrow.ts +++ b/src/subcommands/char/borrow.ts @@ -20,7 +20,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly."); } - const requesterKey = targetArg ?? requester.usermapKey; + const requesterKey = targetArg ?? requester.userKey; if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const char = getCharacterByName(ownerArg, charName); @@ -42,7 +42,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; // Find the voter entry and update their character for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.usermapKey === requesterKey) { + if (entry.userKey === requesterKey) { entry.characterName = char.name; entry.characterClass = char.class; entry.characterLevel = char.level; diff --git a/src/subcommands/char/remove.ts b/src/subcommands/char/remove.ts index f15f9ba..f08d9c0 100644 --- a/src/subcommands/char/remove.ts +++ b/src/subcommands/char/remove.ts @@ -10,18 +10,18 @@ export async function handleCharRemove(interaction: ChatInputCommandInteraction) const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); - const removed = removeCharacter(usermapKey, charName); + const removed = removeCharacter(userKey, charName); if (!removed) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); return void replyAndDelete(interaction, `✅ Character **«${charName}»** removed.`); diff --git a/src/subcommands/char/setActive.ts b/src/subcommands/char/setActive.ts index 19dd25a..e22bb9c 100644 --- a/src/subcommands/char/setActive.ts +++ b/src/subcommands/char/setActive.ts @@ -9,13 +9,13 @@ import path from "path"; const CHARS_PATH = path.join(__dirname, "../../../data/characters.json"); -function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; charName: string } | null { +function findSharedChar(userKey: string, charName: string): { ownerKey: string; charName: string } | null { try { const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { - if (ownerKey === usermapKey) continue; + if (ownerKey === userKey) continue; const char = data.characters?.find( - (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey) + (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey) ); if (char) return { ownerKey, charName: char.name }; } @@ -29,25 +29,25 @@ export async function handleCharSetActive(interaction: ChatInputCommandInteracti const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); // Try own characters first - const set = setActiveCharacter(usermapKey, charName); + const set = setActiveCharacter(userKey, charName); if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`); // Fall back to shared characters - const shared = findSharedChar(usermapKey, charName); + const shared = findSharedChar(userKey, charName); if (shared) { - setSessionBorrow(usermapKey, shared.ownerKey, shared.charName); + setSessionBorrow(userKey, shared.ownerKey, shared.charName); return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`); } diff --git a/src/subcommands/char/setNation.ts b/src/subcommands/char/setNation.ts index fdbb447..41243a1 100644 --- a/src/subcommands/char/setNation.ts +++ b/src/subcommands/char/setNation.ts @@ -12,21 +12,21 @@ export async function handleCharSetNation(interaction: ChatInputCommandInteracti const nation = interaction.options.getString("nation", true) as Nation; const charName = interaction.options.getString("char_name"); // optional, defaults to active - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const targetName = charName ?? getActiveCharacter(usermapKey)?.name; + const targetName = charName ?? getActiveCharacter(userKey)?.name; if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); - const set = setCharacterNation(usermapKey, targetName, nation); + const set = setCharacterNation(userKey, targetName, nation); if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); return replyAndDelete(interaction, `✅ **«${targetName}»** nation set to **${nation}**.`); diff --git a/src/subcommands/char/setStats.ts b/src/subcommands/char/setStats.ts index 57a8038..36f3c23 100644 --- a/src/subcommands/char/setStats.ts +++ b/src/subcommands/char/setStats.ts @@ -13,21 +13,21 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio const def = interaction.options.getInteger("def") ?? undefined; const heal = interaction.options.getInteger("heal") ?? undefined; - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const targetName = charName ?? getActiveCharacter(usermapKey)?.name; + const targetName = charName ?? getActiveCharacter(userKey)?.name; if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); - const set = setCharacterStats(usermapKey, targetName, { atk, def, heal }); + const set = setCharacterStats(userKey, targetName, { atk, def, heal }); if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`); diff --git a/src/subcommands/char/share.ts b/src/subcommands/char/share.ts index ba72a0e..f6f7747 100644 --- a/src/subcommands/char/share.ts +++ b/src/subcommands/char/share.ts @@ -5,6 +5,7 @@ import { getCharacterByName } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; import fs from "fs"; import path from "path"; +import { clearPersistentPreference } from "../../systems/borrow"; const CHARS_PATH = path.join(__dirname, "../../../data/characters.json"); @@ -29,7 +30,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction): return void replyAndDelete(interaction, "❌ Only officers can share other players' characters."); } - const ownerKey = ownerArg ?? user.usermapKey; + const ownerKey = ownerArg ?? user.userKey; if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const char = getCharacterByName(ownerKey, charName); @@ -48,7 +49,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction): charEntry.sharedWith.push(targetKey); saveCharacters(raw); - return void replyAndDelete(interaction, `✅ **«${charName}»** is now permanently shared with **${targetKey}**.`); + return void replyAndDelete(interaction, `✅ **${charName}** is now permanently shared with **${targetKey}**.`); } export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise { @@ -64,7 +65,7 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares."); } - const ownerKey = ownerArg ?? user.usermapKey; + const ownerKey = ownerArg ?? user.userKey; if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const raw = loadRawChars(); @@ -78,5 +79,8 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey); saveCharacters(raw); + // Clear persistent preference if the borrower was using this char + clearPersistentPreference(targetKey); + return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`); } \ No newline at end of file diff --git a/src/subcommands/impersonate.ts b/src/subcommands/impersonate.ts new file mode 100644 index 0000000..21f11a2 --- /dev/null +++ b/src/subcommands/impersonate.ts @@ -0,0 +1,126 @@ +import { + ChatInputCommandInteraction, + ButtonInteraction, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + EmbedBuilder, +} from "discord.js"; +import { cfg } from "@systems/config"; +import { hasOfficerRole } from "@systems/users"; +import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate"; +import { replyAndDelete } from "@utils"; + +const PAGE_SIZE = 5; // users per page (1 button each + nav row) + +function buildImpersonateEmbed(users: { userKey: string; aliases: string[] }[], page: number, currentImpersonation: string | null): EmbedBuilder { + const start = page * PAGE_SIZE; + const pageUsers = users.slice(start, start + PAGE_SIZE); + + const lines = pageUsers.map((u, i) => { + const display = u.aliases[0] ?? u.userKey; + const current = u.userKey === currentImpersonation ? " ← *active*" : ""; + return `${start + i + 1}. **${display}** (${u.userKey})${current}`; + }); + + return new EmbedBuilder() + .setTitle("🎭 Impersonate User") + .setDescription( + (currentImpersonation + ? `Currently impersonating: **${currentImpersonation}**\n\n` + : "Not impersonating anyone.\n\n") + + lines.join("\n") + ) + .setColor(0x5865f2) + .setFooter({ text: `Page ${page + 1} of ${Math.ceil(users.length / PAGE_SIZE)}` }); +} + +function buildImpersonateButtons( + users: { userKey: string; aliases: string[] }[], + page: number, + realDiscordId: string +): ActionRowBuilder[] { + const start = page * PAGE_SIZE; + const pageUsers = users.slice(start, start + PAGE_SIZE); + const hasNext = users.length > (page + 1) * PAGE_SIZE; + const hasPrev = page > 0; + const rows: ActionRowBuilder[] = []; + + // One button per user on this page + const userButtons = pageUsers.map((u) => + new ButtonBuilder() + .setCustomId(`impersonate_set:${u.userKey}:${page}`) + .setLabel(`${u.aliases[0] ?? u.userKey}`) + .setStyle(ButtonStyle.Primary) + ); + if (userButtons.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...userButtons)); + } + + // Nav + release row + const navBtns: ButtonBuilder[] = []; + if (hasPrev) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page - 1}`).setLabel("← Prev").setStyle(ButtonStyle.Secondary)); + if (hasNext) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page + 1}`).setLabel("Next →").setStyle(ButtonStyle.Secondary)); + navBtns.push(new ButtonBuilder().setCustomId("impersonate_release").setLabel("🚫 Release").setStyle(ButtonStyle.Danger)); + rows.push(new ActionRowBuilder().addComponents(...navBtns)); + + return rows; +} + +// Slash command handler +export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, cfg("officerRoles"))) { + return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); + } + + const users = getRegisteredUsers(); + if (users.length === 0) return void replyAndDelete(interaction, "❌ No registered users found in usermap.json."); + + const current = getImpersonation(interaction.user.id); + const embed = buildImpersonateEmbed(users, 0, current); + const buttons = buildImpersonateButtons(users, 0, interaction.user.id); + + await interaction.reply({ embeds: [embed], components: buttons, ephemeral: true }); +} + +// Button handler +export async function handleImpersonateButton(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + const realId = interaction.user.id; + + if (customId.startsWith("impersonate_set:")) { + const parts = customId.split(":"); + const userKey = parts[1]; + const page = parseInt(parts[2] ?? "0"); + + setImpersonation(realId, userKey); + const users = getRegisteredUsers(); + const embed = buildImpersonateEmbed(users, page, userKey); + const buttons = buildImpersonateButtons(users, page, realId); + + await interaction.update({ embeds: [embed], components: buttons }); + return; + } + + if (customId.startsWith("impersonate_page:")) { + const page = parseInt(customId.split(":")[1]); + const current = getImpersonation(realId); + const users = getRegisteredUsers(); + const embed = buildImpersonateEmbed(users, page, current); + const buttons = buildImpersonateButtons(users, page, realId); + + await interaction.update({ embeds: [embed], components: buttons }); + return; + } + + if (customId === "impersonate_release") { + clearImpersonation(realId); + const users = getRegisteredUsers(); + const embed = buildImpersonateEmbed(users, 0, null); + const buttons = buildImpersonateButtons(users, 0, realId); + + await interaction.update({ embeds: [embed], components: buttons }); + return; + } +} \ No newline at end of file diff --git a/src/subcommands/poll/inject.ts b/src/subcommands/poll/inject.ts index 9606327..eee0625 100644 --- a/src/subcommands/poll/inject.ts +++ b/src/subcommands/poll/inject.ts @@ -1,17 +1,18 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { cfg } from "../../systems/config"; import { polls, updatePollMessage } from "../../systems/poll"; -import { getActiveCharacter } from "../../systems/characters"; -import { resolveNation } from "../../systems/nations"; +import { getEffectiveCharacter } from "../../systems/borrow"; import { nowFormatted, resolveMessage } from "../../systems/messages"; import { replyAndDelete } from "../../utils"; import { VoteEntry } from "../../types"; export async function handleInject(interaction: ChatInputCommandInteraction): Promise { - const usermapKey = interaction.options.getString("name", true); + const userKey = interaction.options.getString("name", true); const voteType = interaction.options.getString("vote_type", true) as "yes" | "no"; + console.log("[inject] called"); const slot = [...polls.keys()][0]; + console.log("[inject] slot:", slot); if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); const state = polls.get(slot)!; @@ -19,23 +20,24 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr return void replyAndDelete(interaction, "❌ Poll is locked or confirmed."); } - const char = getActiveCharacter(usermapKey); - console.log(`[DEBUG inject] usermapKey=${usermapKey} char=${JSON.stringify(char)}`); - if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${usermapKey}**.`); + const { char, borrowedFrom } = getEffectiveCharacter(userKey); + console.log(`[DEBUG inject] userKey=${userKey} char=${JSON.stringify(char)} borrowedFrom=${JSON.stringify(borrowedFrom)}`); + if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${userKey}**.`); - // Use a synthetic userId based on usermapKey to avoid collisions - const syntheticId = `injected:${usermapKey}`; + // Use a synthetic userId based on userKey to avoid collisions + const syntheticId = `injected:${userKey}`; const now = nowFormatted(); - const publicMsg = resolveMessage("public", voteType, 1, usermapKey, null, null); + const publicMsg = resolveMessage("public", voteType, 1, userKey, null, null); - const entry: VoteEntry = { - usermapKey, +const entry: VoteEntry = { + userKey, displayName: char.name, characterName: char.name, characterClass: char.class, characterLevel: char.level, characterNation: char.nation, + borrowedFrom: borrowedFrom ?? undefined, votedAt: now, publicMessage: publicMsg ?? undefined, }; @@ -50,31 +52,31 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot); - return void replyAndDelete(interaction, `✅ Injected **${usermapKey}** as **${voteType}**.`); + return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`); } export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise { - const usermapKey = interaction.options.getString("name", true); + const userKey = interaction.options.getString("name", true); const slot = [...polls.keys()][0]; if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); const state = polls.get(slot)!; - const syntheticId = `injected:${usermapKey}`; + const syntheticId = `injected:${userKey}`; - // Also try removing real votes by scanning for usermapKey + // Also try removing real votes by scanning for userKey let removed = false; for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.usermapKey === usermapKey || id === syntheticId) { + if (entry.userKey === userKey || id === syntheticId) { state.yes.delete(id); state.no.delete(id); removed = true; } } - if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${usermapKey}**.`); + if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${userKey}**.`); const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot); - return void replyAndDelete(interaction, `✅ Vote removed for **${usermapKey}**.`); + return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`); } \ No newline at end of file diff --git a/src/subcommands/poll/seed.ts b/src/subcommands/poll/seed.ts index 9bdc5ae..955bd4b 100644 --- a/src/subcommands/poll/seed.ts +++ b/src/subcommands/poll/seed.ts @@ -29,18 +29,18 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom let skipped = 0; for (const [discordUsername, entry] of Object.entries(usermap)) { - const usermapKey = typeof entry === "string" ? entry : entry.file; - const { char, borrowedFrom } = getEffectiveCharacter(usermapKey); + const userKey = typeof entry === "string" ? entry : entry.file; + const { char, borrowedFrom } = getEffectiveCharacter(userKey); if (!char) { skipped++; continue; } - const syntheticId = `injected:${usermapKey}`; + const syntheticId = `injected:${userKey}`; if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; } const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null); const voteEntry: VoteEntry = { - usermapKey, + userKey, displayName: char.name, characterName: char.name, characterClass: char.class, diff --git a/src/subcommands/rank/get.ts b/src/subcommands/rank/get.ts index 604132d..b446497 100644 --- a/src/subcommands/rank/get.ts +++ b/src/subcommands/rank/get.ts @@ -13,29 +13,29 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks."); } - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const week = getCurrentWeek(); const goal = cfg("wRankGoal"); const weekKey = getWeekKey(); for (const nation of ["capella", "procyon"] as const) { - const entry = week.entries[nation].find((e) => e.usermapKey === usermapKey); + const entry = week.entries[nation].find((e) => e.userKey === userKey); if (!entry) continue; const isDone = entry.tgCount >= goal; const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0; const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : ""; const bringer = getBringer(entry.nation); - const isBringer = bringer === usermapKey && isDone; + const isBringer = bringer === userKey && isDone; const lines = [ `**${entry.characterName}** · ${entry.nation}`, @@ -48,5 +48,5 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P return void replyAndDelete(interaction, lines); } - return void replyAndDelete(interaction, `❌ No rank found for **${usermapKey}** this week.`); + return void replyAndDelete(interaction, `❌ No rank found for **${userKey}** this week.`); } diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index 8418540..c1050be 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -16,7 +16,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): 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.usermapKey && isDone + 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})`; diff --git a/src/subcommands/score/get.ts b/src/subcommands/score/get.ts index 1b82559..f91aa98 100644 --- a/src/subcommands/score/get.ts +++ b/src/subcommands/score/get.ts @@ -3,6 +3,7 @@ import { cfg } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { normalizeSlot, detectSlot } from "../../systems/scores"; import { loadResult, todayString } from "../../systems/history"; +import { getEmoji } from "../../systems/emojis"; import { replyAndDelete } from "../../utils"; export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise { @@ -12,41 +13,51 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction): const slotArg = interaction.options.getString("slot"); if (nameArg && !isOfficer) { - return void replyAndDelete(interaction, "❌ Only officers can view other players' scores."); + return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.", true); } - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.", true); let slot: number | null = null; if (slotArg) { slot = normalizeSlot(slotArg); - if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); + if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true); } else { - slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; + slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; } const result = loadResult(todayString(), slot); - if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`); + if (!result) return void replyAndDelete(interaction, `❌ No result found for **${slot}:00** TG today.`, true); - const score = result.scores.find((s) => s.usermapKey === usermapKey); - if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${usermapKey}** in the ${slot}:00 TG.`); + // Find score — check both direct ownership and borrowed (playedBy) + const score = result.scores.find( + (s) => s.userKey === userKey || (s as any).playedBy === userKey + ); + + if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${userKey}** in the **${slot}:00** TG.`, true); + + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const playedBy = (score as any).playedBy && (score as any).playedBy !== score.userKey + ? `\n*(played by ${(score as any).playedBy})*` + : ""; const lines = [ - `**${score.characterName}** (${score.class} · ${score.nation})`, - `**Points:** ${score.pts}`, - score.atk !== undefined ? `**ATK:** ${score.atk}` : null, - score.def !== undefined ? `**DEF:** ${score.def}` : null, - score.heal !== undefined ? `**HEAL:** ${score.heal}` : null, - `**Submitted:** ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}`, + `**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`, + `${scoreEmoji} **${score.pts}** pts`, + score.atk !== undefined ? `ATK: ${score.atk}` : null, + score.def !== undefined ? `DEF: ${score.def}` : null, + score.heal !== undefined ? `HEAL: ${score.heal}` : null, + `*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`, ].filter(Boolean).join("\n"); - return void replyAndDelete(interaction, lines); -} + return void replyAndDelete(interaction, lines, true); +} \ No newline at end of file diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts index b813206..caf271d 100644 --- a/src/subcommands/score/set.ts +++ b/src/subcommands/score/set.ts @@ -1,57 +1,75 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; -import { resolveUser, hasOfficerRole } from "../../systems/users"; -import { submitScore, detectSlot, normalizeSlot } from "../../systems/scores"; -import { getActiveCharacter } from "../../systems/characters"; -import { resolveNation } from "../../systems/nations"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { resolveUser, hasOfficerRole } from "@systems/users"; +import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { replyAndDelete } from "@utils"; +import { getEmoji } from "@systems/emojis"; export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise { - const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); - - const nameArg = interaction.options.getString("name"); - const ptsArg = interaction.options.getInteger("pts", true); - const slotArg = interaction.options.getString("slot"); - - // Officers can specify a name, players cannot - let usermapKey: string | null; - let targetMember = member; + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const nameArg = interaction.options.getString("name"); + const ptsArg = interaction.options.getInteger("pts", true); + const slotArg = interaction.options.getString("slot"); + const kills = interaction.options.getInteger("k") ?? undefined; + const deaths = interaction.options.getInteger("d") ?? undefined; + const k = interaction.options.getInteger("k") ?? undefined; + const d = interaction.options.getInteger("d") ?? undefined; + const atk = interaction.options.getInteger("atk") ?? undefined; + const def = interaction.options.getInteger("def") ?? undefined; + const heal = interaction.options.getInteger("heal") ?? undefined; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); - const char = getActiveCharacter(usermapKey); + const { char, borrowedFrom } = getEffectiveCharacter(userKey); if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first."); - // Resolve slot let slot: number | null = null; if (slotArg) { slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); } else { - slot = detectSlot(); - if (slot === null) { - return void replyAndDelete(interaction, "❌ No active score window detected. Specify a slot explicitly."); - } + slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; } await submitScore({ - usermapKey, + userKey: borrowedFrom ?? userKey, + playedBy: borrowedFrom ? userKey : undefined, characterName: char.name, cls: char.class, nation: char.nation, pts: ptsArg, + k, + d, slot, + atk, + def, + heal, submittedByOfficer: isOfficer && !!nameArg, }); - return void replyAndDelete(interaction, `✅ Score of **${ptsArg}** submitted for **${char.name}** (${slot}:00 TG).`); -} + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; + const kdNote = kills !== undefined && deaths !== undefined ? `\n${kdEmoji} ${kills}/${deaths}` : ""; + const statsNote = [ + atk !== undefined ? `ATK: ${atk}` : null, + def !== undefined ? `DEF: ${def}` : null, + heal !== undefined ? `HEAL: ${heal}` : null, + ].filter(Boolean).join(" · "); + + return void replyAndDelete(interaction, + `✅ ${scoreEmoji} **${ptsArg}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + true + ); +} \ No newline at end of file diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts index f7192e4..43a6de6 100644 --- a/src/subcommands/switch.ts +++ b/src/subcommands/switch.ts @@ -1,22 +1,30 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../systems/config"; -import { resolveUser, hasOfficerRole } from "../systems/users"; -import { setActiveCharacter, getActiveCharacter, getCharacterByName } from "../systems/characters"; -import { setSessionBorrow, getSessionBorrow } from "../systems/borrow"; -import { polls, updatePollMessage } from "../systems/poll"; -import { replyAndDelete } from "../utils"; +import { cfg } from "@systems/config"; +import { resolveUser, hasOfficerRole } from "@systems/users"; +import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; +import { + getEffectiveCharacter, + setSessionBorrow, + setPersistentPreference, + clearPersistentPreference, + clearSessionBorrowForUser, +} from "@systems/borrow"; +import { polls, updatePollMessage } from "@systems/poll"; +import { getClassEmoji } from "@systems/emojis"; +import { replyAndDelete } from "@src/utils"; +import { format } from "@format"; import fs from "fs"; import path from "path"; const CHARS_PATH = path.join(__dirname, "../../data/characters.json"); -function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; char: any } | null { +function findSharedChar(userKey: string, charName: string): { ownerKey: string; char: any } | null { try { const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { - if (ownerKey === usermapKey) continue; + if (ownerKey === userKey) continue; const char = data.characters?.find( - (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey) + (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey) ); if (char) return { ownerKey, char }; } @@ -24,10 +32,9 @@ function findSharedChar(usermapKey: string, charName: string): { ownerKey: strin return null; } -// Reverse-lookup: find Discord userId for a usermapKey from current poll voters -function findUserIdInPoll(state: any, usermapKey: string): string | null { +function findVoteIdInPoll(state: any, userKey: string): string | null { for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.usermapKey === usermapKey) return id; + if (entry.userKey === userKey) return id; } return null; } @@ -38,49 +45,85 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + // Resolve the target character without switching yet let resolvedChar: any = null; let borrowedFrom: string | null = null; - // Try own characters first - const set = setActiveCharacter(usermapKey, charName); - if (set) { - resolvedChar = getActiveCharacter(usermapKey); + const ownChar = getCharacterByName(userKey, charName); + if (ownChar) { + resolvedChar = ownChar; } else { - // Fall back to shared characters - const shared = findSharedChar(usermapKey, charName); + const shared = findSharedChar(userKey, charName); if (shared) { - setSessionBorrow(usermapKey, shared.ownerKey, shared.char.name); - resolvedChar = shared.char; - borrowedFrom = shared.ownerKey; - console.log(`[borrow] Session borrow set: ${usermapKey} → ${shared.ownerKey}:${shared.char.name}`); - console.log(`[borrow] Current borrows:`, getSessionBorrow(usermapKey)); + resolvedChar = shared.char; + borrowedFrom = shared.ownerKey; } } if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); - // Update poll embed if user has voted + // If already active — just show current state without switching + const current = getEffectiveCharacter(userKey); + if (current.char?.name === resolvedChar.name) { + const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; + const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : ""; + return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); + } + + // Check if target character is already in the active poll by another player const slot = [...polls.keys()][0]; if (slot !== undefined) { - const state = polls.get(slot)!; - const userId = nameArg - ? findUserIdInPoll(state, usermapKey) - : interaction.user.id; + const state = polls.get(slot)!; + for (const [id, entry] of state.yes.entries()) { + const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`; + if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) { + const slotHour = state.slot; + 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 + ); + } + return void replyAndDelete(interaction, + `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + true + ); + } + } + } - if (userId && (state.yes.has(userId) || state.no.has(userId))) { + // Now actually switch + if (borrowedFrom) { + setSessionBorrow(userKey, borrowedFrom, resolvedChar.name); + setPersistentPreference(userKey, borrowedFrom, resolvedChar.name); + } else { + setActiveCharacter(userKey, charName); + clearPersistentPreference(userKey); + clearSessionBorrowForUser(userKey); + resolvedChar = getActiveCharacter(userKey); + } + + // Update poll embed if user has already voted + if (slot !== undefined) { + const state = polls.get(slot)!; + const voteId = findVoteIdInPoll(state, userKey); + + if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) { const updateEntry = (map: Map) => { - const entry = map.get(userId); + const entry = map.get(voteId); if (entry) { entry.characterName = resolvedChar.name; entry.characterClass = resolvedChar.class; @@ -97,6 +140,7 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr } } - const borrowNote = borrowedFrom ? ` (shared by **${borrowedFrom}**)` : ""; - return void replyAndDelete(interaction, `✅ Switched to **${charName}**${borrowNote}.`); + const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; + const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); } \ No newline at end of file diff --git a/src/systems/borrow.ts b/src/systems/borrow.ts index 30c790f..73206a6 100644 --- a/src/systems/borrow.ts +++ b/src/systems/borrow.ts @@ -1,15 +1,44 @@ import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; +import fs from "fs"; +import path from "path"; import { BorrowRequest } from "../types"; import { cfg } from "./config"; import { getCharacterByName } from "./characters"; -// Active borrow requests (pending accept/decline) -const pendingRequests: Map = new Map(); // key: `${ownerKey}:${requesterKey}` +const PREFS_PATH = path.join(__dirname, "../../data/sessionPreferences.json"); -// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start +// ─── Persistent preferences ─────────────────────────────────────────────────── +let _prefs: Record = {}; + +function loadPrefs(): void { + try { _prefs = JSON.parse(fs.readFileSync(PREFS_PATH, "utf8")); } + catch { _prefs = {}; } +} + +function savePrefs(): void { + try { fs.writeFileSync(PREFS_PATH, JSON.stringify(_prefs, null, 2)); } + catch (err) { console.error("Failed to save sessionPreferences.json:", err); } +} + +loadPrefs(); + +export function setPersistentPreference(userKey: string, ownerKey: string, charName: string): void { + _prefs[userKey] = { ownerKey, charName }; + savePrefs(); +} + +export function clearPersistentPreference(userKey: string): void { + delete _prefs[userKey]; + savePrefs(); +} + +export function getPersistentPreference(userKey: string): { ownerKey: string; charName: string } | null { + return _prefs[userKey] ?? null; +} + +// ─── Active borrow requests ─────────────────────────────────────────────────── +const pendingRequests: Map = new Map(); const sessionBorrows: Map = new Map(); - -// DM message IDs for updating borrow request messages const borrowDmMessages: Map = new Map(); function requestKey(ownerKey: string, requesterKey: string): string { @@ -31,9 +60,7 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] { export function addPendingRequest(request: BorrowRequest): void { const key = requestKey(request.ownerKey, request.requesterKey); const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0; - pendingRequests.set(key, request); - if (expiry > 0) { setTimeout(() => { if (pendingRequests.get(key)?.requestedAt === request.requestedAt) { @@ -56,7 +83,7 @@ export function getDmMessage(ownerKey: string, requesterKey: string): { channelI return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null; } -// Session borrow management +// ─── Session borrows ────────────────────────────────────────────────────────── export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void { sessionBorrows.set(requesterKey, { ownerKey, charName }); } @@ -70,22 +97,20 @@ export function clearSessionBorrows(): void { borrowDmMessages.clear(); } -// Check if a user can use a character (owns it or has share/borrow access) export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean { if (requesterKey === ownerKey) return true; - - // Check persistent share const char = getCharacterByName(ownerKey, charName); if (char?.sharedWith?.includes(requesterKey)) return true; - - // Check session borrow const borrow = getSessionBorrow(requesterKey); if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true; - return false; } -// Send borrow request DM to owner, fall back to poll channel ephemeral +export function clearSessionBorrowForUser(userKey: string): void { + sessionBorrows.delete(userKey); +} + +// ─── DM notifications ───────────────────────────────────────────────────────── export async function sendBorrowRequestDM( client: Client, ownerDiscordId: string, @@ -117,7 +142,6 @@ export async function sendBorrowRequestDM( const msg = await dm.send({ content, components: [row] }); storeDmMessage(ownerKey, requesterKey, dm.id, msg.id); } catch { - // DM failed — fall back to poll channel ephemeral if (fallbackChannel) { await fallbackChannel.send({ content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`, @@ -126,7 +150,6 @@ export async function sendBorrowRequestDM( } } -// Update DM after accept/decline to disable buttons export async function updateBorrowDM( client: Client, ownerKey: string, @@ -140,19 +163,30 @@ export async function updateBorrowDM( const message = await channel.messages.fetch(dm.messageId); const status = accepted ? "✅ Accepted" : "❌ Declined"; await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] }); - } catch { - // DM may have been deleted, ignore - } + } catch {} } -// Returns the effective active character for a user — session borrow takes priority over own active char -export function getEffectiveCharacter(usermapKey: string): { char: any; borrowedFrom: string | null } { - const { getActiveCharacter, getCharacterByName } = require("./characters"); - const borrow = getSessionBorrow(usermapKey); +// ─── Effective character resolution ────────────────────────────────────────── +export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } { + const { getActiveCharacter, getCharacterByName: getChar } = require("./characters"); + + // 1. Session borrow (temporary, resets on poll start) + const borrow = getSessionBorrow(userKey); if (borrow) { - const char = getCharacterByName(borrow.ownerKey, borrow.charName); + const char = getChar(borrow.ownerKey, borrow.charName); if (char) return { char, borrowedFrom: borrow.ownerKey }; } - const char = getActiveCharacter(usermapKey); + + // 2. Persistent preference (survives restarts and poll resets) + const pref = getPersistentPreference(userKey); + console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`); + if (pref) { + const char = getChar(pref.ownerKey, pref.charName); + if (char) return { char, borrowedFrom: pref.ownerKey }; + clearPersistentPreference(userKey); + } + + // 3. Own active character + const char = getActiveCharacter(userKey); return { char: char ?? null, borrowedFrom: null }; } \ No newline at end of file diff --git a/src/systems/characters.ts b/src/systems/characters.ts index 207f430..14a8645 100644 --- a/src/systems/characters.ts +++ b/src/systems/characters.ts @@ -23,52 +23,52 @@ function saveAccounts(): void { fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2)); } -export function getCharacters(usermapKey: string): Character[] { - return _chars[usermapKey]?.characters ?? []; +export function getCharacters(userKey: string): Character[] { + return _chars[userKey]?.characters ?? []; } -export function getActiveCharacter(usermapKey: string): Character | null { - return getCharacters(usermapKey).find((c) => c.active) ?? null; +export function getActiveCharacter(userKey: string): Character | null { + return getCharacters(userKey).find((c) => c.active) ?? null; } -export function getCharacterByName(usermapKey: string, name: string): Character | null { - return getCharacters(usermapKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null; +export function getCharacterByName(userKey: string, name: string): Character | null { + return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null; } -export function getCharacterByClass(usermapKey: string, cls: ClassKey): Character | null { +export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null { // Returns the active character of that class, or first found - const chars = getCharacters(usermapKey).filter((c) => c.class === cls); + const chars = getCharacters(userKey).filter((c) => c.class === cls); return chars.find((c) => c.active) ?? chars[0] ?? null; } -export function addCharacter(usermapKey: string, char: Omit): boolean { - if (!_chars[usermapKey]) _chars[usermapKey] = { characters: [] }; - const exists = _chars[usermapKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase()); +export function addCharacter(userKey: string, char: Omit): boolean { + if (!_chars[userKey]) _chars[userKey] = { characters: [] }; + const exists = _chars[userKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase()); if (exists) return false; // If no active character, set this one as active - const hasActive = _chars[usermapKey].characters.some((c) => c.active); - _chars[usermapKey].characters.push({ ...char, active: !hasActive }); + const hasActive = _chars[userKey].characters.some((c) => c.active); + _chars[userKey].characters.push({ ...char, active: !hasActive }); saveCharacters(); return true; } -export function removeCharacter(usermapKey: string, name: string): boolean { - if (!_chars[usermapKey]) return false; - const before = _chars[usermapKey].characters.length; - _chars[usermapKey].characters = _chars[usermapKey].characters.filter( +export function removeCharacter(userKey: string, name: string): boolean { + if (!_chars[userKey]) return false; + const before = _chars[userKey].characters.length; + _chars[userKey].characters = _chars[userKey].characters.filter( (c) => c.name.toLowerCase() !== name.toLowerCase() ); - if (_chars[usermapKey].characters.length === before) return false; + if (_chars[userKey].characters.length === before) return false; // If we removed the active one, set the first remaining as active - if (!_chars[usermapKey].characters.some((c) => c.active) && _chars[usermapKey].characters.length > 0) { - _chars[usermapKey].characters[0].active = true; + if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) { + _chars[userKey].characters[0].active = true; } saveCharacters(); return true; } -export function setActiveCharacter(usermapKey: string, name: string): boolean { - const chars = _chars[usermapKey]?.characters; +export function setActiveCharacter(userKey: string, name: string): boolean { + const chars = _chars[userKey]?.characters; if (!chars) return false; const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase()); if (!target) return false; @@ -78,8 +78,8 @@ export function setActiveCharacter(usermapKey: string, name: string): boolean { return true; } -export function setCharacterNation(usermapKey: string, name: string, nation: Nation): boolean { - const char = getCharacterByName(usermapKey, name); +export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean { + const char = getCharacterByName(userKey, name); if (!char) return false; char.nation = nation; saveCharacters(); @@ -87,11 +87,11 @@ export function setCharacterNation(usermapKey: string, name: string, nation: Nat } export function setCharacterStats( - usermapKey: string, + userKey: string, name: string, stats: { atk?: number; def?: number; heal?: number } ): boolean { - const char = getCharacterByName(usermapKey, name); + const char = getCharacterByName(userKey, name); if (!char) return false; if (!char.stats) char.stats = {}; Object.assign(char.stats, stats); @@ -100,12 +100,12 @@ export function setCharacterStats( } // ─── Account data ───────────────────────────────────────────────────────────── -export function getAccountData(usermapKey: string): AccountData { - return _accounts[usermapKey] ?? {}; +export function getAccountData(userKey: string): AccountData { + return _accounts[userKey] ?? {}; } -export function setAccountData(usermapKey: string, data: Partial): void { - if (!_accounts[usermapKey]) _accounts[usermapKey] = {}; - Object.assign(_accounts[usermapKey], data); +export function setAccountData(userKey: string, data: Partial): void { + if (!_accounts[userKey]) _accounts[userKey] = {}; + Object.assign(_accounts[userKey], data); saveAccounts(); } diff --git a/src/systems/config.ts b/src/systems/config.ts index cb7bfb0..39c1d05 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -36,6 +36,7 @@ function getDefaults(): Required { showNationTotalsInHeader: false, showNoInNationField: false, borrowRequestExpiryMs: 0, // 0 = never expire + conflictReclaimBehavior: "revert", }; } diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts new file mode 100644 index 0000000..97f513b --- /dev/null +++ b/src/systems/conflict.ts @@ -0,0 +1,311 @@ +import { + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + EmbedBuilder, + ButtonInteraction, + TextChannel, +} from "discord.js"; +import { cfg } from "@systems/config"; +import { getCharacters, setActiveCharacter } from "@systems/characters"; +import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow"; +import { getImpersonation } from "@systems/impersonate"; +import { polls, updatePollMessage } from "@systems/poll"; +import { resolveMessage, nowFormatted } from "@systems/messages"; +import { getClassEmoji } from "@systems/emojis"; +import { format } from "@systems/format"; +import { Character } from "@types"; + +// ─── Config ─────────────────────────────────────────────────────────────────── +const RECLAIM_STYLE = ButtonStyle.Secondary; +const SWITCH_STYLE = ButtonStyle.Secondary; +const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false"; +const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false"; + +// ─── State ──────────────────────────────────────────────────────────────────── +const pendingConflicts = new Map(); + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder { + const emojiStr = getClassEmoji(char.class); + const emoji = format.emoji(emojiStr); + btn.setLabel(`${char.level} ${char.name}`); + if (emoji) btn.setEmoji(emoji as any); + return btn; +} + +function buildConflictEmbed(borrowerKey: string, char: Character): EmbedBuilder { + return new EmbedBuilder() + .setTitle("⚠️ Character Conflict") + .setDescription( + `**${format.char(char)}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.` + ) + .setColor(0xe8a317); +} + +function buildConflictButtons( + ownerKey: string, + borrowerKey: string, + borrowedCharName: string, + ownerId: string, + allChars: Character[], + page: number +): ActionRowBuilder[] { + const PAGE_SIZE = 4; + const otherChars = allChars.filter((c) => c.name !== borrowedCharName); + const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + const hasMore = otherChars.length > (page + 1) * PAGE_SIZE; + const hasPrev = page > 0; + const rows: ActionRowBuilder[] = []; + + // Row 1: Reclaim button + const reclaimId = `conflict_reclaim:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}`; + pendingConflicts.set(reclaimId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page }); + const borrowed = allChars.find((c) => c.name === borrowedCharName); + const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE); + if (borrowed) { + reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`); + const emojiStr = getClassEmoji(borrowed.class); + const emoji = format.emoji(emojiStr); + if (emoji) reclaimBtn.setEmoji(emoji as any); + } else { + reclaimBtn.setLabel(`Reclaim ${borrowedCharName}`); + } + rows.push(new ActionRowBuilder().addComponents(reclaimBtn)); + + // Row 2: Switch buttons + const charButtons = pageChars.map((char) => { + const id = `conflict_switch:${ownerKey}:${borrowerKey}:${char.name}:${ownerId}`; + pendingConflicts.set(id, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page }); + return applyCharToButton(new ButtonBuilder().setCustomId(id).setStyle(SWITCH_STYLE), char); + }); + if (charButtons.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...charButtons)); + } + + // Row 3: Pagination + const navButtons: ButtonBuilder[] = []; + if (hasPrev) { + const prevId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page - 1}`; + pendingConflicts.set(prevId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page - 1 }); + navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary)); + } + if (hasMore) { + const nextId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page + 1}`; + pendingConflicts.set(nextId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page + 1 }); + navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary)); + } + if (navButtons.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...navButtons)); + } + + return rows; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── +export async function showConflictEmbed( + interaction: ButtonInteraction, + ownerKey: string, + borrowerKey: string, + borrowedChar: Character, + allOwnerChars: Character[] +): Promise { + const embed = buildConflictEmbed(borrowerKey, borrowedChar); + const buttons = buildConflictButtons(ownerKey, borrowerKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0); + await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true }); +} + +export async function handleConflictButton(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + + // ── Pagination ────────────────────────────────────────────────────────────── + if (customId.startsWith("conflict_page:")) { + const parts = customId.split(":"); + const ownerKey = parts[1]; + const borrowerKey = parts[2]; + const charName = parts[3]; + const ownerId = parts[4]; + const page = parseInt(parts[5]); + + const allChars = getCharacters(ownerKey); + const borrowed = allChars.find((c) => c.name === charName); + if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true }); + + const embed = buildConflictEmbed(borrowerKey, borrowed); + const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page); + await interaction.update({ embeds: [embed], components: buttons }); + return; + } + + // ── Switch to another char ────────────────────────────────────────────────── + if (customId.startsWith("conflict_switch:")) { + const parts = customId.split(":"); + const ownerKey = parts[1]; + const borrowerKey = parts[2]; + const newCharName = parts[3]; + const ownerId = parts[4]; + + setActiveCharacter(ownerKey, newCharName); + clearSessionBorrowForUser(ownerKey); + + const impersonating = getImpersonation(ownerId); + const voteId = impersonating ? `impersonated:${impersonating}` : ownerId; + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + if (state && AUTO_VOTE_ON_SWITCH) { + const guild = interaction.guild!; + const member = await guild.members.fetch(ownerId); + const { char } = getEffectiveCharacter(ownerKey); + const now = nowFormatted(); + const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null); + + state.yes.set(voteId, { + userKey: ownerKey, + displayName: member.nickname ?? member.user.globalName ?? member.user.username, + characterName: char?.name, + characterClass: char?.class, + characterLevel: char?.level, + characterNation: char?.nation, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + } + + const newChar = getCharacters(ownerKey).find((c) => c.name === newCharName); + const charDisplay = newChar ? format.char(newChar) : newCharName; + + await interaction.update({ + embeds: [new EmbedBuilder() + .setTitle("🔄 Switched") + .setDescription(`${charDisplay}${AUTO_VOTE_ON_SWITCH ? " — voted Yes." : ""}`) + .setColor(0x57f287)], + components: [], + }); + return; + } + + // ── Reclaim ───────────────────────────────────────────────────────────────── + if (customId.startsWith("conflict_reclaim:")) { + const parts = customId.split(":"); + const ownerKey = parts[1]; + const borrowerKey = parts[2]; + const charName = parts[3]; + const ownerId = parts[4]; + + const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert"; + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + let borrowerDiscordId: string | undefined; + let borrowerVoteType: "yes" | "no" = "yes"; // default to yes + + if (state) { + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + const isYes = state.yes.has(id); + if (entry.userKey === borrowerKey) { + borrowerVoteType = isYes ? "yes" : "no"; + // Capture borrower's Discord ID for notification + borrowerDiscordId = (entry as any).discordId; + + if (reclaimBehavior === "remove") { + state.yes.delete(id); + state.no.delete(id); + } else { + // Clear borrow so getEffectiveCharacter returns own char + clearSessionBorrowForUser(borrowerKey); + clearPersistentPreference(borrowerKey); + const { char: ownChar } = getEffectiveCharacter(borrowerKey); + if (ownChar) { + entry.characterName = ownChar.name; + entry.characterClass = ownChar.class; + entry.characterLevel = ownChar.level; + entry.characterNation = ownChar.nation; + entry.borrowedFrom = undefined; + } else { + state.yes.delete(id); + state.no.delete(id); + } + } + break; + } + } + + // Owner joins with their reclaimed char + const guild = interaction.guild!; + const member = await guild.members.fetch(ownerId); + const impersonating = getImpersonation(ownerId); + const voteId = impersonating ? `impersonated:${impersonating}` : ownerId; + + setActiveCharacter(ownerKey, charName); + clearSessionBorrowForUser(ownerKey); + const { char } = getEffectiveCharacter(ownerKey); + const now = nowFormatted(); + const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null); + + state.yes.set(voteId, { + userKey: ownerKey, + displayName: member.nickname ?? member.user.globalName ?? member.user.username, + characterName: char?.name, + characterClass: char?.class, + characterLevel: char?.level, + characterNation: char?.nation, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + + // 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.`, + }); + } + } catch { + // DM may be disabled — silently ignore + } + } + } + + const borrowed = getCharacters(ownerKey).find((c) => c.name === charName); + const charDisplay = borrowed ? format.char(borrowed) : charName; + + await interaction.update({ + embeds: [new EmbedBuilder() + .setTitle("↩️ Reclaimed") + .setDescription(`${charDisplay} reclaimed from **${borrowerKey}**.`) + .setColor(0x57f287)], + components: [], + }); + return; + } +} \ No newline at end of file diff --git a/src/systems/format.ts b/src/systems/format.ts new file mode 100644 index 0000000..899e0b0 --- /dev/null +++ b/src/systems/format.ts @@ -0,0 +1,65 @@ +import { ClassKey, Nation } from "@src/types"; +import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis"; + +// ─── Individual formatters ──────────────────────────────────────────────────── + +export interface CharDisplayOptions { + emoji?: boolean; // show class emoji (default: true) + level?: boolean; // show level (default: true) +} + +/** + * Format a character for display in embeds and messages. + * Output: <:class:> 79 «Flash» + */ +function char( + c: { class: ClassKey; level: number; name: string }, + options?: CharDisplayOptions +): string { + const showEmoji = options?.emoji ?? true; + const showLevel = options?.level ?? true; + const classStr = showEmoji ? (getClassEmoji(c.class) || c.class) : c.class; + const levelStr = showLevel ? `${c.level} ` : ""; + return `${classStr} ${levelStr}${c.name}`.trim(); +} + +/** + * Format a nation name with its emoji. + * Output: <:capella:> Capella + */ +function nation(n: Nation): string { + const emoji = getNationEmoji(n); + return emoji ? `${emoji} ${n}` : n; +} + +/** + * Format a score line. + * Output: <:score:> 3000 <:kd:> 32/18 + */ +function score(pts: number, k?: number, d?: number): string { + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : ""; + return `${scoreEmoji} ${pts}${kdStr}`; +} + +/** + * Parse a Discord custom emoji string to an object for ButtonBuilder.setEmoji() + * Input: "<:fb:1511020923510194428>" + * Output: { name: "fb", id: "1511020923510194428" } + */ +function emoji(emojiStr: string): { name: string; id: string } | string | null { + if (!emojiStr) return null; + const match = emojiStr.match(/^<:(\w+):(\d+)>$/); + if (match) return { name: match[1], id: match[2] }; + return emojiStr; +} + +// ─── Namespace export ───────────────────────────────────────────────────────── + +export const format = { + char, + nation, + score, + emoji, +}; \ No newline at end of file diff --git a/src/systems/history.ts b/src/systems/history.ts index af92803..9ca89e8 100644 --- a/src/systems/history.ts +++ b/src/systems/history.ts @@ -42,7 +42,7 @@ export function upsertScore(score: TGScore): void { // Overwrite existing score for this player+slot result.scores = result.scores.filter( - (s) => !(s.usermapKey === score.usermapKey && s.slot === score.slot && s.date === score.date) + (s) => !(s.userKey === score.userKey && s.slot === score.slot && s.date === score.date) ); result.scores.push(score); saveResult(result); diff --git a/src/systems/impersonate.ts b/src/systems/impersonate.ts new file mode 100644 index 0000000..71e4005 --- /dev/null +++ b/src/systems/impersonate.ts @@ -0,0 +1,44 @@ +import fs from "fs"; +import path from "path"; + +const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false"; +const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false"; + +// realDiscordId → userKey being impersonated +const impersonations = new Map(); + +export function setImpersonation(realDiscordId: string, userKey: string): void { + impersonations.set(realDiscordId, userKey); +} + +export function clearImpersonation(realDiscordId: string): void { + impersonations.delete(realDiscordId); +} + +export function getImpersonation(realDiscordId: string): string | null { + return impersonations.get(realDiscordId) ?? null; +} + +export function clearAllImpersonations(): void { + if (IMPERSONATE_RESET_ON_POLL) impersonations.clear(); +} + +export function shouldShowIndicator(): boolean { + return IMPERSONATE_INDICATOR; +} + +// Returns all registered userKeys from usermap.json +export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] { + try { + const usermap = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") + ); + return Object.entries(usermap).map(([, entry]: [string, any]) => { + const fileKey = typeof entry === "string" ? entry : entry.file; + const aliases = typeof entry === "object" ? (entry.aliases ?? []) : []; + return { userKey: fileKey, aliases }; + }); + } catch { + return []; + } +} \ No newline at end of file diff --git a/src/systems/nations.ts b/src/systems/nations.ts index 06d471a..5278789 100644 --- a/src/systems/nations.ts +++ b/src/systems/nations.ts @@ -3,10 +3,10 @@ import { Nation } from "../types"; import { getActiveCharacter } from "./characters"; // Resolve a user's nation — character nation takes priority over Discord role -export function resolveNation(member: GuildMember, usermapKey: string | null): Nation | null { +export function resolveNation(member: GuildMember, userKey: string | null): Nation | null { // 1. Active character's nation - if (usermapKey) { - const char = getActiveCharacter(usermapKey); + if (userKey) { + const char = getActiveCharacter(userKey); if (char) return char.nation; } diff --git a/src/systems/poll.ts b/src/systems/poll.ts index c15c5d5..239f629 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -55,7 +55,7 @@ export function resetPollOverrides(): void { function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { const format = cfg("charDisplayFormat"); const nation = entry.characterNation; - const wRankEntry = entry.usermapKey ? getEntry(entry.usermapKey, nation ?? "Capella") : null; + const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; let wrank = ""; if (wRankEntry) { @@ -94,9 +94,9 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { .trim(); // Bringer title — independent of W.Rank so override always shows - if (nation && entry.usermapKey) { + if (nation && entry.userKey) { const bringer = getBringer(nation); - if (bringer && bringer === entry.usermapKey) { + if (bringer && bringer === entry.characterName) { const emoji = nation === "Capella" ? (getEmoji("luminous_bringer") || "🔆") : (getEmoji("storm_bringer") || "⚡"); @@ -225,8 +225,11 @@ export async function updatePollMessage(channel: TextChannel, slot: number, over export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { resetPollOverrides(); - const { clearSessionBorrows } = require("./borrow"); + const { clearSessionBorrows } = require("@systems/borrow"); + const { clearAllImpersonations } = require("@systems/impersonate"); + clearSessionBorrows(); + clearAllImpersonations(); const state: PollState = { messageId: null, slot: slot.tgHour, @@ -242,7 +245,7 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { const serverNickname = member.nickname ?? null; @@ -250,17 +253,18 @@ export function createVoteEntry( const displayName = serverNickname ?? globalNickname ?? discordUsername; const { getEffectiveCharacter } = require("./borrow"); - const { char, borrowedFrom: bf } = usermapKey - ? getEffectiveCharacter(usermapKey) + const { char, borrowedFrom: bf } = userKey + ? getEffectiveCharacter(userKey) : { char: null, borrowedFrom: null }; + console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`); return { - usermapKey: usermapKey ?? (undefined as any), + userKey: userKey ?? (undefined as any), displayName, characterName: char?.name, characterClass: char?.class, characterLevel: char?.level, - characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined), + characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined), borrowedFrom: bf ?? undefined, }; } \ No newline at end of file diff --git a/src/systems/scores.ts b/src/systems/scores.ts index 87cf1a1..0be3927 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -53,11 +53,14 @@ export function detectSlot(): number | null { } export interface ScoreSubmission { - usermapKey: string; + userKey: string; // owner's key (score goes here) + playedBy?: string; // borrower's key if different from owner characterName: string; cls: ClassKey; nation: Nation; pts: number; + k?: number; + d?: number; slot: number; date?: string; atk?: number; @@ -71,11 +74,14 @@ export function submitScore(sub: ScoreSubmission): void { const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`; const score: TGScore = { - usermapKey: sub.usermapKey, + userKey: sub.userKey, + playedBy: sub.playedBy, characterName: sub.characterName, class: sub.cls, nation: sub.nation, pts: sub.pts, + k: sub.k, + d: sub.d, atk: sub.atk, def: sub.def, heal: sub.heal, @@ -86,5 +92,5 @@ export function submitScore(sub: ScoreSubmission): void { }; upsertScore(score); - recordScore(sub.usermapKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); + recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); } diff --git a/src/systems/users.ts b/src/systems/users.ts index 9338e87..fb0b2c5 100644 --- a/src/systems/users.ts +++ b/src/systems/users.ts @@ -1,4 +1,5 @@ import { GuildMember } from "discord.js"; +import { getImpersonation } from "./impersonate"; import { ResolvedUser } from "../types"; import { getUsermapEntry } from "./messages"; import { getActiveCharacter } from "./characters"; @@ -10,15 +11,21 @@ export async function resolveUser(member: GuildMember): Promise { const globalNickname = member.user.globalName ?? null; const displayName = serverNickname ?? globalNickname ?? discordUsername; - const entry = getUsermapEntry(discordUsername); - const usermapKey = entry?.file ?? null; - const aliases = entry?.aliases ?? []; - const activeChar = usermapKey ? getActiveCharacter(usermapKey) : null; + // Check for active impersonation + const impersonatedKey = getImpersonation(member.user.id); + + const entry = impersonatedKey ? { file: impersonatedKey, aliases: [] } : getUsermapEntry(discordUsername); + const userKey = entry?.file ?? null; + const aliases = entry?.aliases ?? []; + const activeChar = userKey ? getActiveCharacter(userKey) : null; + // lookupUsername is used for message system lookups — use impersonated key if impersonating + const lookupUsername = impersonatedKey ?? discordUsername; return { userId: member.user.id, discordUsername, - usermapKey, + lookupUsername, + userKey, displayName, serverNickname, globalNickname, @@ -28,13 +35,13 @@ export async function resolveUser(member: GuildMember): Promise { } // Resolve a user by their usermap key (for officer commands using arg) -export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType } { +export function resolveByUsermapKey(key: string): { userKey: string; activeCharacter: ReturnType } { return { - usermapKey: key, + userKey: key, activeCharacter: getActiveCharacter(key), }; } export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean { return member.roles.cache.some((r) => officerRoles.includes(r.name)); -} +} \ No newline at end of file diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 26861ce..63f6a0a 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -45,7 +45,7 @@ export function getWeek(weekKey: string): WRankWeek | null { // Add or update a score submission for a player export function recordScore( - usermapKey: string, + userKey: string, characterName: string, cls: ClassKey, nation: Nation, @@ -56,11 +56,11 @@ export function recordScore( const week = ensureWeek(weekKey); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; - const existing = list.find((e) => e.usermapKey === usermapKey); + const existing = list.find((e) => e.characterName === characterName); if (existing) { // Check if this slot was already counted - const alreadyCounted = week.scoreIndex[usermapKey]?.includes(historyKey); + const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey); if (!alreadyCounted) { existing.weeklyPoints += pts; existing.tgCount += 1; @@ -75,7 +75,7 @@ export function recordScore( existing.nation = nation; } else { list.push({ - usermapKey, + userKey, characterName, class: cls, nation, @@ -87,9 +87,10 @@ export function recordScore( } // Update score index - if (!week.scoreIndex[usermapKey]) week.scoreIndex[usermapKey] = []; - if (!week.scoreIndex[usermapKey].includes(historyKey)) { - week.scoreIndex[usermapKey].push(historyKey); + const indexKey = characterName; + if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = []; + if (!week.scoreIndex[indexKey].includes(historyKey)) { + week.scoreIndex[indexKey].push(historyKey); } recomputeRanks(week, nation); @@ -101,7 +102,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void { const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints); sorted.forEach((entry, i) => { - const live = list.find((e) => e.usermapKey === entry.usermapKey)!; + const live = list.find((e) => e.characterName === entry.characterName)!; live.previousRank = live.currentRank || undefined; live.currentRank = i + 1; }); @@ -117,14 +118,14 @@ function updateBringer(week: WRankWeek): void { const qualified = week.entries[nation] .filter((e) => e.tgCount >= goal) .sort((a, b) => a.currentRank - b.currentRank); - week.bringer[nation] = qualified[0]?.usermapKey ?? null; + week.bringer[nation] = qualified[0]?.characterName ?? null; } } -export function setBringerOverride(nation: Nation, usermapKey: string): void { +export function setBringerOverride(nation: Nation, charName: string): void { const week = ensureWeek(getWeekKey()); - if (nation === "Capella") week.bringer.capellaOverride = usermapKey; - else week.bringer.procyonOverride = usermapKey; + if (nation === "Capella") week.bringer.capellaOverride = charName; + else week.bringer.procyonOverride = charName; saveWRank(); } @@ -142,10 +143,10 @@ export function getBringer(nation: Nation): string | null { return week.bringer.procyonOverride ?? week.bringer.procyon; } -export function getEntry(usermapKey: string, nation: Nation): WRankEntry | null { +export function getEntry(characterName: string, nation: Nation): WRankEntry | null { const week = getCurrentWeek(); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; - return list.find((e) => e.usermapKey === usermapKey) ?? null; + return list.find((e) => e.characterName === characterName) ?? null; } // Called every Monday 00:00 by cron @@ -153,4 +154,4 @@ export function resetWeek(): void { // Week is already archived in _data by weekKey — just ensure next week exists ensureWeek(getWeekKey(new Date())); saveWRank(); -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index fcd59bf..9f5b2e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,7 +47,7 @@ export interface Character { } export interface CharacterMap { - [usermapKey: string]: { + [userKey: string]: { characters: Character[]; }; } @@ -68,7 +68,7 @@ export interface AccountData { } export interface AccountMap { - [usermapKey: string]: AccountData; + [userKey: string]: AccountData; } // ─── Usermap ───────────────────────────────────────────────────────────────── @@ -94,7 +94,7 @@ export interface TGSlot { // ─── Poll ──────────────────────────────────────────────────────────────────── export interface VoteEntry { - usermapKey: string; + userKey: string; displayName: string; // server nickname → global nickname → username characterName?: string; // active character name at time of vote characterClass?: ClassKey; // snapshotted @@ -107,6 +107,7 @@ export interface VoteEntry { publicMessageOverride?: string;// set by officer via /tg poll set-message ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral borrowedFrom?: string // Borrowed character from who + discordId?: string; // real Discord ID of the voter (for notifications) } export interface PollState { @@ -123,11 +124,13 @@ export interface PollState { // ─── Scores ────────────────────────────────────────────────────────────────── export interface TGScore { - usermapKey: string; + userKey: string; characterName: string; class: ClassKey; nation: Nation; // snapshotted at submission time pts: number; + k?: number; + d?: number; atk?: number; def?: number; heal?: number; @@ -135,6 +138,7 @@ export interface TGScore { slot: number; // TG hour date: string; // YYYY-MM-DD submittedByOfficer: boolean; + playedBy?: string; // userKey of who actually played (if borrowed) } // ─── TG Result ─────────────────────────────────────────────────────────────── @@ -160,7 +164,7 @@ export interface TGResult { // ─── W.Rank ────────────────────────────────────────────────────────────────── export interface WRankEntry { - usermapKey: string; + userKey: string; characterName: string; // snapshotted class: ClassKey; // snapshotted nation: Nation; // snapshotted @@ -177,10 +181,10 @@ export interface WRankWeek { procyon: WRankEntry[]; }; scoreIndex: { - [usermapKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"] + [userKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"] }; bringer: { - capella: string | null; // usermapKey of bringer, null if none qualified + capella: string | null; // userKey of bringer, null if none qualified procyon: string | null; capellaOverride?: string; // manually set by officer procyonOverride?: string; @@ -195,7 +199,7 @@ export interface WRankData { export interface BringerState { currentWeek: string; // "2026-W22" - capella: string | null; // usermapKey + capella: string | null; // userKey procyon: string | null; capellaOverride?: string; procyonOverride?: string; @@ -231,6 +235,7 @@ export interface BotConfig { showNationTotalsInHeader?: boolean; showNoInNationField?: boolean; borrowRequestExpiryMs?: number; // 0 = never expire (default) + conflictReclaimBehavior?: "revert" | "remove" } // ─── Messages ──────────────────────────────────────────────────────────────── @@ -266,7 +271,8 @@ export interface EmojiMap { export interface ResolvedUser { userId: string; discordUsername: string; // interaction.user.username - usermapKey: string | null; // resolved from usermap + userKey: string | null; // resolved from usermap + lookupUsername: string | null; displayName: string; // server nickname → global nickname → username serverNickname: string | null; globalNickname: string | null; diff --git a/src/utils.ts b/src/utils.ts index b5395e3..e45af70 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,18 +1,56 @@ import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; -const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false"; -const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); +// Poll vote confirmation messages (Yes/No button responses) +const POLL_EPHEMERAL_ENABLED = process.env.POLL_EPHEMERAL_ENABLED !== "false"; +// Command output messages (score, rank, status etc.) — always on by default +const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false"; +const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); -export async function replyAndDelete( - interaction: ChatInputCommandInteraction | ButtonInteraction, +// For poll button responses +export async function pollReplyAndDelete( + interaction: ButtonInteraction, content: string | null ): Promise { - if (!content || !EPHEMERAL_ENABLED) { - if (interaction.isButton()) return void interaction.deferUpdate(); - return void interaction.deferReply({ ephemeral: true }).then(() => interaction.deleteReply()).catch(() => {}); - } - const reply = await interaction.reply({ content, ephemeral: true, fetchReply: true }); - if (EPHEMERAL_DELETE_MS > 0) { - setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + if (!content || !POLL_EPHEMERAL_ENABLED) return; + try { + const reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true }); + if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + } catch (err: any) { + console.error("[pollReplyAndDelete] error:", err.message); } } + +// For command responses — always sends regardless of POLL_EPHEMERAL_ENABLED +export async function replyAndDelete( + interaction: ChatInputCommandInteraction | ButtonInteraction, + content: string | null, + forceShow = false // set true for meaningful output that should always show +): Promise { + const enabled = forceShow || COMMAND_EPHEMERAL_ENABLED; + + if (!content || !enabled) { + if (interaction.isButton()) { + if (!interaction.deferred && !interaction.replied) await interaction.deferUpdate(); + return; + } + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + await interaction.deleteReply().catch(() => {}); + } + return; + } + + try { + let reply: any; + if (interaction.deferred || interaction.replied) { + reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true }); + } else { + reply = await interaction.reply({ content, ephemeral: true, fetchReply: true }); + } + if (reply && EPHEMERAL_DELETE_MS > 0) { + setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + } + } catch (err: any) { + console.error("[replyAndDelete] error:", err.message); + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 83a6139..4752ddc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,8 +9,25 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"], + "@data/*": ["data/*"], + "@messages/*": ["messages/*"], + "@tgHistory/*": ["data/tg-history/*"], + "@scripts/*": ["scripts/*"], + "@systems/*": ["src/systems/*"], + "@commands/*": ["src/commands/*"], + "@subcommands/*": ["src/subcommands/*"], + "@handlers/*": ["src/handlers/*"], + "@utils": ["src/utils"], + "@types": ["src/types"], + "@format": ["src/systems/format"], + "@emojis": ["src/systems/emojis"], + "@characters": ["src/systems/characters"], + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] -} +} \ No newline at end of file From 29aa8537235201e3fcc06b05afe82a62cd330560 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 4 Jun 2026 03:08:01 +0100 Subject: [PATCH 2/3] 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| z@cy69kuNHLnD0(?2Jw|qlP_1Z&cHQ4-#6W>)+ns;kHXMjwl7(K6-!Piq8(B-UPe#M z)RsN|buoQCFT}EN@(jef=k*=`SmlQ=)q;~K^2dV(JVFy6EM|W*LBD5UfxMI77{>SY zOB@$%A`Q1@qT>o71sza%&F6B>q3m{9l1sE{1R#b&GlUfHeX@0;NN@ytK@s@*HC$?nC2q^mA!~NA@$J1LthD4|2-}Yxg z1CKslYVAS;Cjcc4oENBEFs~Q1;Dea}%{5h-68@OA&mSD{#=RH3#7grx8rmR8tihF< zA5MWp!8%~y4#?ze*jqEaXQ{{~RrHov4nSx-7qOR5D?e?1muZNfW0J1+Fwuf*KfjUM zjc5kb50mZK)ITzYYtX(aO(mAUKPm1a(dNgtq~Oj381@%@_$vmIPwIIE#~CZn95^W* zn1z8_9er@@;46RCwtd&Cc6rLvcE#`jl#}X2zrVl$k zbZ3Q36g}5q<{~(DP3Xg0^5<{_LpHoq=c)$B-5e45FeJ+ zFIqX!&fTPn+q7CDyF$%$Qn2zNJP5{~AVLH)0d5aLbx`E8OxEm4(GwbyirEiad=r>R zcxXntaDg}4Ep$7(?h}-REVJ19vTRdX4)|8rXG~kVG_wK?r|$PJG(l09(tjX)aOo^Q zn$viVzoC3dXDjjmqCc}rY690A*i)FC!^uF3m<^%iX7O?CyUXh$;zp%i*pLC0@2m$I z{LWwryP}W8bnkd8pu=XW>Ki5t=N>&x@{_Js1Hmi8nu&W*7iT1L#u}3BP0*=?=iycQ z@#-xL7CP3L6^BuQ>}-v(7j8U5v%NvrXK)GclXDpf^4b=}!8#Fk3+FbhbO+FUhavo! z_CD!39X^ikHc#_bv5T?CS$Iv1c(2#T zu*XrK-19w zVN4s~RTc2Q)IW#hPpAYN^3J&PRL_738}raQ0yKjY7JsU}4mu>V;Syw2eJ8g5tXNxGw8AZ**SZ_qRjW!?)@13AZA|UVO7GG81WK zH#wiG@FuJ%j;(HUyi$=Z#Z~YznKxwG>7y=|bifa^D|!^?@(;8DUv`f@MEmi!-_G9ytQ-f6AIfLXZst4 z2v481sx2|-R$dG9HkE5;Vt*DG1f`MzRy|!ET!JJwj2FDwB_aioya3oenf_GtsO|*JdRVHm>C;d zwCz*~CAHn&42XH=S$E(Pkx6AvU7|I0Po*yFl zSS^v|36{<3GTH@RW>v&ozby3&u^ZvF}}EY zqV{q@g5pp8QebtRbm1u1*z=Udrg|ZmNvPrffS>Z!-jB~GblxeuWfSU^Eh!;1HUVJC`!~vAQl{dMYYa7*pKA|&I4lvr zg|6*F=^PD^h?Y3C?Vr~B9)@m%orH`yxt5p8yoL-V>HFiUqo{qEN8@g=su2;pt8BNL z0dreD8PpQ#DnjCw{v%^Y=|P7cy0=pVJ8Fh=2m>7~^X7_-+Xeyv(~Sv4BU*m-)k~J? zy6~`mNK?mn3q`cx6!5rH@M**_1b*ifAlAfGjj-S zH@X%&sSP0{7{QQy142r(k3ycX1t+;3hVdy9c{o=%1-;h5l0L0>Rhd|6oxXipY^`-Ct^6q|4TcNfoU&(dW7 z*X=?2%U>XX0-6@xV5keC0Mo&%HT4b>3tu=3w??ywsO8=e>7H*lDBOgJ9G*G5^cc~+ zl_Jn_AfXXo<=i&~L#q`yaI`U}2E;I3T5u02IHYb9E=1>4gg%)?ojgUquxou^WNRlf zjX#EMc2&(;r>!Rs?#qg2H!1LKCI=zycy_(Im7FWR9T7!~s(tnJ#m>zUoW<9l*0GEq z{~T>E2w+l2K~-D1;%E&jhoitcWxKWYhbn3t>6)zp>mMVB(c8{pbGfjo)T}Q}`TgVM z*~IVcU3T5-;=MO-Ta4}#AwDMDy?c^_Wh2A}xRaN3^4k2kI5)I;xrie`jZCHLgo$cN zSM9t&2mP~}+d>?amf{$S0uNniKHl)7T|YO$E=a9^9B%&-4;P3SQgiqEFk)NxS!|fA zAt?il`ju+X9IAFMtSv&G{Zka(1IDrf2ag_BZ3?I?{AP|{a*h$`D3qph#U_e;MFR(1jh1k;AOx1Gqb-RJnnzHv6`^z!d& zpXHwN+BW#Te{rMxjcsfs+4w4qy1HFc3$G|A)8>eqeAs2JPnbIU9K z1cES@V$!LOa~}Twj^9oUbm`(wQGn??eby{Cgdj7Fz~@9-ttD^l2TUqO9bAK zZcB0K@ceEM;c62Ux|Q-*qt?5ThbU(?L^MO z%>Z;kxNTfdby6;2F2MS-_Sdo`Ge!+cc~jcJ{8p1(x)(T8AcZM8g1M2liMmLN*BK>{ zs_>QVRXBWX{fk458HBWx8)w9*<`$|TmKW0bRVZ@FkOfuv(lYG3N~Ro@&wgRQY2^?Y zT=!}4j5;T=TOn$?*sOR}iMa*;lO2~fSRu>gL>NGYYm=J7_Vea^BMKb|!{kYBsMLgSrP*ku#FN+OCUI}|fR)#;H$0{$ zymxsRaHK$i#>@9l5bZHx0Gt+M>3b0B z-;M?Hx)F$B>ej!_n+_n&qoFne(Ddyh}`p8)Vh$lat$_1fk=L4{W zUTfyjckHw_a#}j68SsI)F@~nsT{oOd%H{?0;sU-#n@=E&B0G*ySakbM00ifw#(y}1 zoYlt6nqeq8SSQ4O-#74eACBMiQ2_TFj=vP;w_f>K2xb7^M}vaF&^`dGow7&sPa%8y zHy{t0vE!cwWO+ zTwEK^Z80gSO&MhhQ$pQ`JPt)R_K^qzseY0$m`h8pHN^tJ7(h0sU^Rfyr#zuv5SSR$ zKPCL!40vw_+AlpIO_OOJYpWr}gn6F31e41MO-If|C)e$feXN?(@G3&;?sEuTdb*~i z*6eWMz z3H_Bw@t*zdo;41X$FSya=gc@@w+bqcoADEu#%u_>9N`X6HgOQUe8~|Yde<_06HdcGP29B zC$v2m?nVIxYg}0&I0IKI6Hv>3DwNGGWl?EOJX6|V0)ux(iadtX>~SXio3I;HG)7cF z0)RR6=|A-ET_xi)ksZ)5>S1h67UETsQbJ3k0NV9v!(Wb6yJqjxH2qBZyOkT?D%zj7 z>!w1|Rq{QCt=^2ej$?5R-vQwNDYRdjK+UI7OC5>m`~k@UYK_YuW3FG-UV5*;Vt1$ z0YD>|Oq%wWs6SoHF0UXr`#XeCWONW)8KHnYgHQs326A(miC_v?ET9;|49JxV?3uWt zb@JtjKT{(T(1rR=jpP)&fKWMhl?jF9=+ZjXXubft9#q56nefrS(f3Tr`%;~cc`ds( z7m*8pE%-ew!QT|XQKJ7=FF(F#cyypE#Oj{SJCLpbT=qB`zxM+Gz9P_kdSBgrP9cYk zCu|K$=m}6OTMekuerwW<6$P-&03PQbOZdC`@ozD~-xM~!R0y**k7RdpS9D$Z!;KL&d|HT;M`n-eo&e%q8l(3*= zB2Ae6Pc?f3Jzrj`nXeRq*2bW5J66*EJdsD!`n-vT<{#0(QKSEr5h5(Hm9sxbsOI}j z{P&|HfIxweEOeg&0Msm-GnOW?fKn0u0t1%^R|5;a<_KK04nWEf{Adh&jUoJ(5n|l; z8T40U0g|0-jjEb3jlhL`DhRa|P6Bq0y6V>5VgMX)G|j&${LH)MXgL6$b>ZE7`NST{ ziAtZx=MV~dFaij4d+}q0C02?bry@U31Qx($rBT@RAV83-7^o2FrUwKxlc4+gV3*VFDcxd?_l90(rATJ9D5xs3QEVV{dEnP&;L6<-&m0#1t(jKuQwgi}Xo^ zEUzOY2>;b+^WP7<@#9Q!1X@{vmJM4H2a%Lc+A2O4`Z;s*?{ftRN@WwZiv4dz05q+? zsjLYvoJy@p%^ZNKD1p?&uLFUP#P{W9j~&za6o5Z=X#CP(%i6gz44_B=K$)Qb9#h~m z7%#)Gqy1lX2>(3604ztKT)Xqf@CJt3r0uU zgrX@8!Kde!BM218UpH|yUiYs8_#aI0B_PnufWq(t?5Y#c0s?KIVhOuMSh9tGJqrIA zU2-nOaNpNpw}9RhHoGSosCEE=rV(dM=%sKI*O)craaf1~a1tSzszpfmy%+&dGC5L! zAo&bv)Sxz4ku}S&6>MOQ0eBS4{V{@cas*TISiHtx2k_sR&=;^f43+SW9^2JCfWip^ zg|gYD&Mx%EKZwwOZw%o-A2t8Ch)v5+4g_<<&Ykx#0XPHTYdB8`e$9kkYNpX0uLtnv zXi6OsA|6)|N{@deI0r2g!2*N2M+gwi@l|5ygdZT?Yoavh_LmW=`sV208F~>Wa7XlC z{RvZYMFI>ob59cfU9AP^;~=z#YS4#&H$t!WF@*ne)coIO0)N8*f>e#WC*76WF;%Uyp!w4B`CyY&hG@b_$+LC>dFpqWtFyL!qH2{$)Z6kmpVE0+{oJC}L??;}h zNr?%(4WWMIhl#y!$x4=E%+X+y{VLHO#Laqg1mVAekm2`jBJelZ&lUh!_P-YZTIhKd z)HJAJ$I!JX!(oKni94KYaw48xXWVnb^)`vnVeMoK76256{hwk1o(7&@L#QCVeFW$7 zafE`kA3FBFCNEizazJ74dul*&GKTxc@bZ)dTyUqP{? z9IXrh^aTpbhwyp~+u>C@UW#`A*WyOF&R%aACcviP3(%;4O6WBqr-H?`z1D(UqKzM4 zaxGsIeKAOEI_cV^8F-$1x7Mys2tR+71fM_aZ$w3q8w75PKwLNkf3%|-P@wxd4Yf^_ zyNavR{9j=kaq^rWhNkABxB_xB3C#xA9DUvAok18)zU*-qLfysFNVmS$ z(ve#yd+|2E^h}(BQ**WmVc!oBlPTA668)07ymGbcVL;E(8k>i1qMR|>m!j~0+0YJ~ zh7Ujr`=zvh3%ll>(rI4)**g3OlJJ)Z({YDzqMkw+Z~m+TNNwd-JQ_qI~2mRMB-jJ6W|P>VPK{?3sZt#I6#X6(_bLu z2_O>)%wX4W0GVb0<^q6rf%jS!ZB4YzbI|^C*q+rk-phNik?h_3h=w~(yI-XPJr`EBzj(eo5I33y?IFEF3S058$X>(#o29TP3V$ntF z@cK!l=|lMfq{1G}Nu-4VJr!=66R%;mFqrn&1j6G)mCkNz(U8uhDt_h+>A84?Qvm?- z)L+^Q&svAN2QYek7S!SRd4vL@7Q=-IU&Ph~WUEsY9R8k_8xPLowykkPmPfb-@CZU$ z>ZyT*|JnFJr3yAF@kbMWtFeU@u0!HM_qF3vvG4Bh#|H#}Q?O?|EaJg#6YPNZ$7>Xg zuBFU+ZGSD&@Mj2BeJzHmLphJox!8*OGzl3R90YRu05p`f;>F$Ke+eNO<>ZpW{|rKR z7^!K81p-pik5<@XY5vxDsD(q&C4mRzL=v6~JMi9p4P}SEi=}Z4FYvvWqwxPR z`COjAzi9vgxB0EAHmT-c6Mk?0c1rkxbUF3V#{_ca8ZU6^3j$U+le4HY;TK3b{8Frx z_wk#=J@o1Le-;IS2Lb$F==BiV{#n*PlobG4hkeC==hbR-!TDzXT>$@l0RXrJ;7I^~ z2)#;eE&za(Od>V<^a_7#*u7G`8wQotMyzWShpSA=VuExHyOrDSF$X9x7DVv-fPlw0 zB?%v&z0egD{Q-bqL&%Bf0|L*-^Y@|Gj?{_$<6#23o(IrEo{`2ooOU3<^V>@T0(9g2 zE`Z+za4&r6!50DiZFKl)WvW^uOO5U?qWLw?e_valQ}8~+R&p=^tEq(q0-DX<$Dh;} z1PX@@EqVWIZ12R(d-nbJ7#(=4(?8YO{J#S|iA*aQ_kh4k_yAhN*}{>w6i}CUv5{I; z{U-qL!d4x(wD?aUZsAXP_e|CpH|9C~f4jWx)!hCa!52;pyG_j~gI0XG4-Q08(1 zPMoH|qOtitAkbY`-E)67g7DMc{c~bU(fcj{JQUBf5dcmhWcbYyn#w#HPi?PlCd>dU zFJR>ufELQ4*?8;lGfCyckU>_2K3fd z+Pr~9%367=vSmSlK8Gp$n^b)$b@AoS&LYzL$CSOQ%~ar~r8ktE4FqyHi`qokr6v!u zRyGG`3=@XJDFCRU_qdmaTh@l)&xK90sPKCQK4t#{JNx1nf*=mf0HpS9(lu#*fMSeLG0R(<|5!tY@LsZ2YGcGESQW&nUf3H=oYn=2L&{2@UgRi#*A`)F>uFEs71 z?Y##2Xf>^mfz~KhOAJaSPwl!BkUuoZ44@#8Vi3|ut5hM1m1LA^U8T*g%dPn>ifm=M zSy6!;%r4bsXXtiTi-17k*jJJtfKKS~_&R{}Y{f=VEPd#Ilh{wY?gZp&x%}l?{8=(P%^VX0|0HmNw&Y03^9&=kUhk-V}LdFuoKNRG#pIgsj2*RHVeTRs9HgzRz)ejnjp z1rR8l`>lbU;V3b$R$u~c0tl=+01xFK1Pq3*1+WS@Xy#UFIA{2*ya}V68v1%1JzBHP z8ostwV+sFIpuFmzIR*$6(0ecN{njwSx5CcLDj};JSW`Z!QsG@U0bmG4W6&l}!zkj` zutz1eJsyD7iAZ}doZ*;umjrvPR#L?h-`Va02=EC*jbH-w*?T2?{RTWBpzXa>LD1kw zm0@EpL(8N(aBn|!4m4UPTF>h$2!9KIPCNg-&Tu^?e0~1LVh$+~(E14!3+%lT{t%~? z1h?oOw0bS>DgbCD!3A4bi_a%fbS0dC+5?at7*7`B3*V=jtWB_ic7fk9qVT6E zKj81Lm;(T;qLpSr|3EGf$gvNx#9qv`4_C?wX!!yp6z2cWGZ)>%0Fa=ceg1}#g2u6OZ0 zHvMi0zXb>oXrmTVsxvVFt2Tl&kE~j2fqlreKL-LBX6zyvj;VHJUIEX(535~Td=3N_ zcr0TA^cCze1z)pUQ^PB?*fK9R$zcLW#Vg)uiuH_gxG^n+J-O9b_xer%v zFIJg1Hek2I(uRp>&Y0Hu;1_uisrQYcxh%owasE_=V=e|@RZg}G0Ksnh1s2_lsr}eG z_S#D9#oU>Ih2w^b1S%szzuF$x(?|P^E%+%)n4ya84HhFU$f^jsB=l0^lJxIV zCtI%ln@j*8b+hHsfj;-LN?_pGk2l?3tX4<}*2i|uKY`uHKx;o;#ng{(xo=J?B@=ek|AWwvro7OBuD=Ow9=cxH|B4HUM6NZ%UHZ&9;5D3)>n1)`zt5 zQ?(0{O)c5|e9F<40k8Ecw$D#Y4Y-ti78%$NbZQ_j9bG{SQhX=f_9~;7N zMF+Cgv9FIc0NkvCZzTa)lV|v_)4z(g`?ANg0R9^Cgxh&S@JF3;vp&`Uuvh87^s^`F zM;boaolgSz{y^IQF+un_2xy@|?i0}NdkqL{0C?2{g2V>2Hqz3}IXdcm6TqJ!j80!h z`2Uc6-b^tC_;@QVJ$MxxsICEEeXOi0kV^dFEMSwIH=+sf9{@bJz_pwM@Lv%efH&A@ zYbJn{6VR-w|LPvqdI?s_9Iz??rGXW5==JK||9uj`zXI@E2=nYyxdG=RVEi7o;{Oz2 zG!sA@a+zw2wLaDW@TwO6!U+Zx1gsJ1-9d%$Jb-@#;5`7|3*Z(2r`WZ;7`6Tb0KVn$ zF<7C%K9zuL5Lg4i`hYx1iH}p~J^(-;kw%A}bmOic0PuAHrvcmv;7|mTs{o#e0HCE8 zbFD9dfHnPQH_Cz6#~J|kl;8t)55O8b2Z3rngii1(Y@c&q0C*a}Gm6Q;9glP4oFSb9 zunJ8>*T)(F_8bUkBh@<)m^*}Do3JdI4xU_t8ge3#X8&uxfVJ?i$npON04WP1(A9v1 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 zcmb`JYfw|y7RPsv34})wL`ZlUtw2K%nj47XSbTbuj?1g_>ir4^E>-=i>fQ765w2%L@Yv?vv2q+LQYO zd;mzW0RT<|K%qhHCjjC$127g2fNMSgL}oUl^(%Bgo3?LH(B+c*vd?CL|Y_51qh?&%=+Q7~HQW`Ss98cuHikd6+O3%RSR*A^BIyra8s^ zXM2=>&+qjzrF7aRVQ+6QcC?Ib1{nhCa?4oocRBy35?5M8O6AnBEc9u;Fp32J24WE0^=LNJJAvEfhKES`=OXeKX4)r*XcS|7oCHe zW9Id!jE=xUyu{9XF+%iXE#v8%l2KDSZ_Gb&Q2 z@D4tR$VicgJ~PU{9EE@4LjS&4RdH$lEWaj*qJ3|3Ex{1JP`fE{G_KJ9Tu)3v;#dC| zANT@D>UxFA=FEHB9+_O0uUii!v2WO2Q$yB=aK7~r+fkx9VfF$NUs%SO z^iPgB)la*xmPa_)Q`Q~XXqWY+9q2*Q4#PmqV0o?uv)xY(@gNw32WtTg00i)0S5G?{ z+8vi%*SJ|1qQ+;Q6;%B+up00q{0A@WT+{{t%CYGR=OPl+eu^y>muy$y z;9RpiJch`fEuQ4W-~t?h?wWeU4xgPzTwOy@y zn@baj1Ka*cF5d!CZv<=AGbt&KScu9#g7cma#-i6lXt`3*P;CfNy)$3`{s?CTTx-o2 z@)JA)GC8t1Ag$H>RxI~?X5ASZAM@RSR_-vR{7e7X57DbZ!B$O+&EkeOQRT?06R)R_ zW6xuvX!gE=O<9^V3U|6E_9dYSh~0?|iEMVxx_r51*iOn?Q2S;&3_naznNd(Zob+@6 zwQgAD8TnW<85O8jdMJphe{Jm;pmD}%l^0o2K~#fRf#Mpcyb1-a3jb!J=?4?QH@?d6 z)XA)ZuyAytaXuY~2dcyK6^cqUU=UiWCh9f0RH6h~b|)nU^7IS=|B>$F2MQNT`eBqC zKj;3w1IyTCn4mRJRs`_~4@BW3lV_t)faLk;p7s9$vA%$bG^|5UJ9%hkA`}JNs=89~ zSt~sVj50wbz`|>4{yN~{4l{EILq!KZ_CuM2emM|Z#R-$+BT#tdq~#(i=Vj*2Q06Uc z3B9GuEf@)PVGxef9|5hXO2C#nDa%jx+eVyin6i=X%j$cB!l{E;oDF(3WF z!ihnL1-HuV@kYDC6A6s6seWYpKXXm0I_9?!ElSU1G+&LEDI@2;~Z~b4HKancJvM zE6UYole3b%ZLS6HgT(ZT>LsaN&OoiU_tQ{?a*f-V9uP821a=jvs#R%2roKxn5|c3> zcpV4yqmO7IuKb{|z8eMCg*bsh_*T0#uTUfFu0~~8E;5*_7R*?aKW^NO;ok0Q*%go6 zBoAdF@5$%a`qvZJ#e7c$E}Jok#rKB#fyrwICXhh&`$@9>P+qxjk3)8noseUpPEiaE z5C6J>>bFw|zdW%2JGuV`2L`0Ss^fp(LEIs=2LQn|W-uLr) z?|t9hyO5g9^70Ju1OTsu_}Fv+5)Mf~c?LH*72g%&=GpT2cPat4`4TS)zWmxBcLo>4 zrzZh?6b2xw1+ckry9~gk0o-Q;M1KL`SJsrTd=tQZXF}|;%HN!r5v$V^bV*vo8Qm#xmt~`=)JT!jg0#4Q8AZG zZ+EL(_GSSB6bJ?pJT35qN>kPuA4i+vygIFBHfp@`SIdygrqmcN3u6(cheDnb5#{M} zXTO}$bXai^>Y^O>$#5WxuD`LnhQwY#^8;&v))qE?wO|LRei+BZRAiK0NSs`bz6WbL6_6bKqjGF}}S4)qv20N2V+8hc!g zrw!TeB6?=_0KGi@ccWg^s!Enb1>BFmMFXGoU{pnz}m7m}| z(@kO8OZCfiGCMw_Sh7wp6wG*&JKSzIY?Kxd=}=wuHOo@Ak7z@XXdF@}$tcYIt=-{6 z6R0lgZE}Zz-<59}IYOXHg8uW6H)-joW{lhJy&4PAFE|Br3~N{UMWUS2OkuGEWkGa+ zGYPf)S%p_|A6UR?PD`y^lWx{r|t%jqzY5SPxDPP3UnPUTgI1T2zi~dps$gl&L#O8PeNaf@$ zzv!r|*$y=`X2rc18TaYjtZ(u+9k&f;6=R>|KdeXe$H5lu$AeGmS=8iQQ5Ss+d8DVc kJO0tx((C_k6IUPA4RXi(fAe$BEdc%`#3je}9A|&>H`~hw6aWAK 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 ────────────────────────────────────────────────────────────────── From 8ffe8348bb883e71f39e892e0da82d7eca94bcf4 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Thu, 4 Jun 2026 03:09:12 +0100 Subject: [PATCH 3/3] update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index dbe35b0..2a28802 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ data/bringer.json data/sessionPreferences.json data/tg-history/ +# Emoji data +emoji-uploads/ + # Keep the data directory structure but not the contents !data/.gitkeep !data/tg-history/.gitkeep