From a4b4b5ae8e745514ec32ab245ae9918e657a86d2 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Wed, 3 Jun 2026 01:51:26 +0100 Subject: [PATCH] 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