From 1446cd10fce238889ee5d1e69618f9e872391cd5 Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Mon, 1 Jun 2026 13:36:51 +0100 Subject: [PATCH] initial commit --- .env | 7 + .env.example | 2 + Dockerfile | 11 ++ data/accounts.json | 1 + data/bringer.json | 5 + data/characters.json | 107 +++++++++++ data/config.json | 4 + data/tg-history/2026-06-01-00.json | 29 +++ data/tg-history/2026-06-01-02.json | 29 +++ data/tg-history/2026-06-01-04.json | 29 +++ data/tg-history/2026-06-01-06.json | 29 +++ data/tg-history/2026-06-01-08.json | 29 +++ data/tg-history/2026-06-01-20.json | 40 ++++ data/tg-history/2026-06-01-22.json | 29 +++ data/usermap.json | 13 ++ data/wrank.json | 16 ++ docker-compose.yml | 14 ++ messages/emojis.json | 43 +++++ messages/global.json | 44 +++++ messages/users/ayana.json | 16 ++ messages/users/dey.json | 18 ++ messages/users/flash.json | 30 +++ messages/users/invicjusz.json | 19 ++ messages/users/keira.json | 18 ++ messages/users/marin.json | 20 ++ messages/users/sean.json | 19 ++ messages/users/zephyr.json | 21 ++ nodemon.json | 6 + package.json | 22 +++ src/commands/tg.ts | 297 +++++++++++++++++++++++++++++ src/commands/tgConfig.ts | 211 ++++++++++++++++++++ src/handlers/buttons.ts | 109 +++++++++++ src/handlers/interactions.ts | 42 ++++ src/index.ts | 72 +++++++ src/subcommands/bringer/clear.ts | 10 + src/subcommands/bringer/set.ts | 12 ++ src/subcommands/char/accept.ts | 68 +++++++ src/subcommands/char/add.ts | 32 ++++ src/subcommands/char/borrow.ts | 94 +++++++++ src/subcommands/char/decline.ts | 37 ++++ src/subcommands/char/remove.ts | 28 +++ src/subcommands/char/setActive.ts | 55 ++++++ src/subcommands/char/setNation.ts | 33 ++++ src/subcommands/char/setStats.ts | 34 ++++ src/subcommands/char/share.ts | 82 ++++++++ src/subcommands/history.ts | 34 ++++ src/subcommands/poll/confirm.ts | 46 +++++ src/subcommands/poll/inject.ts | 80 ++++++++ src/subcommands/poll/lock.ts | 21 ++ src/subcommands/poll/purge.ts | 32 ++++ src/subcommands/poll/reload.ts | 12 ++ src/subcommands/poll/seed.ts | 61 ++++++ src/subcommands/poll/setMessage.ts | 84 ++++++++ src/subcommands/poll/start.ts | 31 +++ src/subcommands/poll/status.ts | 30 +++ src/subcommands/poll/unlock.ts | 20 ++ src/subcommands/rank/get.ts | 52 +++++ src/subcommands/rank/post.ts | 39 ++++ src/subcommands/result/post.ts | 46 +++++ src/subcommands/result/set.ts | 30 +++ src/subcommands/result/view.ts | 31 +++ src/subcommands/score/get.ts | 52 +++++ src/subcommands/score/set.ts | 57 ++++++ src/subcommands/switch.ts | 102 ++++++++++ src/systems/borrow.ts | 158 +++++++++++++++ src/systems/characters.ts | 111 +++++++++++ src/systems/config.ts | 66 +++++++ src/systems/emojis.ts | 27 +++ src/systems/history.ts | 92 +++++++++ src/systems/messages.ts | 164 ++++++++++++++++ src/systems/nations.ts | 22 +++ src/systems/poll.ts | 266 ++++++++++++++++++++++++++ src/systems/scores.ts | 90 +++++++++ src/systems/slots.ts | 57 ++++++ src/systems/users.ts | 40 ++++ src/systems/wrank.ts | 156 +++++++++++++++ src/types.ts | 275 ++++++++++++++++++++++++++ src/utils.ts | 18 ++ tsconfig.json | 16 ++ 79 files changed, 4304 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 data/accounts.json create mode 100644 data/bringer.json create mode 100644 data/characters.json create mode 100644 data/config.json create mode 100644 data/tg-history/2026-06-01-00.json create mode 100644 data/tg-history/2026-06-01-02.json create mode 100644 data/tg-history/2026-06-01-04.json create mode 100644 data/tg-history/2026-06-01-06.json create mode 100644 data/tg-history/2026-06-01-08.json create mode 100644 data/tg-history/2026-06-01-20.json create mode 100644 data/tg-history/2026-06-01-22.json create mode 100644 data/usermap.json create mode 100644 data/wrank.json create mode 100644 docker-compose.yml create mode 100644 messages/emojis.json create mode 100644 messages/global.json create mode 100644 messages/users/ayana.json create mode 100644 messages/users/dey.json create mode 100644 messages/users/flash.json create mode 100644 messages/users/invicjusz.json create mode 100644 messages/users/keira.json create mode 100644 messages/users/marin.json create mode 100644 messages/users/sean.json create mode 100644 messages/users/zephyr.json create mode 100644 nodemon.json create mode 100644 package.json create mode 100644 src/commands/tg.ts create mode 100644 src/commands/tgConfig.ts create mode 100644 src/handlers/buttons.ts create mode 100644 src/handlers/interactions.ts create mode 100644 src/index.ts create mode 100644 src/subcommands/bringer/clear.ts create mode 100644 src/subcommands/bringer/set.ts create mode 100644 src/subcommands/char/accept.ts create mode 100644 src/subcommands/char/add.ts create mode 100644 src/subcommands/char/borrow.ts create mode 100644 src/subcommands/char/decline.ts create mode 100644 src/subcommands/char/remove.ts create mode 100644 src/subcommands/char/setActive.ts create mode 100644 src/subcommands/char/setNation.ts create mode 100644 src/subcommands/char/setStats.ts create mode 100644 src/subcommands/char/share.ts create mode 100644 src/subcommands/history.ts create mode 100644 src/subcommands/poll/confirm.ts create mode 100644 src/subcommands/poll/inject.ts create mode 100644 src/subcommands/poll/lock.ts create mode 100644 src/subcommands/poll/purge.ts create mode 100644 src/subcommands/poll/reload.ts create mode 100644 src/subcommands/poll/seed.ts create mode 100644 src/subcommands/poll/setMessage.ts create mode 100644 src/subcommands/poll/start.ts create mode 100644 src/subcommands/poll/status.ts create mode 100644 src/subcommands/poll/unlock.ts create mode 100644 src/subcommands/rank/get.ts create mode 100644 src/subcommands/rank/post.ts create mode 100644 src/subcommands/result/post.ts create mode 100644 src/subcommands/result/set.ts create mode 100644 src/subcommands/result/view.ts create mode 100644 src/subcommands/score/get.ts create mode 100644 src/subcommands/score/set.ts create mode 100644 src/subcommands/switch.ts create mode 100644 src/systems/borrow.ts create mode 100644 src/systems/characters.ts create mode 100644 src/systems/config.ts create mode 100644 src/systems/emojis.ts create mode 100644 src/systems/history.ts create mode 100644 src/systems/messages.ts create mode 100644 src/systems/nations.ts create mode 100644 src/systems/poll.ts create mode 100644 src/systems/scores.ts create mode 100644 src/systems/slots.ts create mode 100644 src/systems/users.ts create mode 100644 src/systems/wrank.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json diff --git a/.env b/.env new file mode 100644 index 0000000..cfaf247 --- /dev/null +++ b/.env @@ -0,0 +1,7 @@ +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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..386a367 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DISCORD_TOKEN=your_bot_token_here +CHANNEL_ID=your_channel_id_here diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e0ff56 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY src/ ./src/ +COPY tsconfig.json ./ + +CMD ["npx", "ts-node", "src/index.ts"] diff --git a/data/accounts.json b/data/accounts.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/data/accounts.json @@ -0,0 +1 @@ +{} diff --git a/data/bringer.json b/data/bringer.json new file mode 100644 index 0000000..eb1e969 --- /dev/null +++ b/data/bringer.json @@ -0,0 +1,5 @@ +{ + "currentWeek": "", + "capella": null, + "procyon": null +} diff --git a/data/characters.json b/data/characters.json new file mode 100644 index 0000000..01cd3b0 --- /dev/null +++ b/data/characters.json @@ -0,0 +1,107 @@ +{ + "flash": { + "characters": [ + { + "name": "«Flash»", + "class": "FB", + "level": 79, + "nation": "Procyon", + "active": false, + "sharedWith": [ + "invicjusz" + ] + }, + { + "name": "»Flash«", + "class": "WI", + "level": 79, + "nation": "Procyon", + "active": true + } + ] + }, + "zephyr": { + "characters": [ + { + "name": "XefronYokuda", + "class": "FA", + "level": 79, + "nation": "Capella", + "active": true + } + ] + }, + "dey": { + "characters": [ + { + "name": "«Deystroyer»", + "class": "BL", + "level": 79, + "nation": "Capella", + "active": true + } + ] + }, + "keira": { + "characters": [ + { + "name": "«Keira»", + "class": "WI", + "level": 79, + "nation": "Capella", + "active": true + } + ] + }, + "ayana": { + "characters": [ + { + "name": "«MonkeyHunter»", + "class": "DM", + "level": 79, + "nation": "Procyon", + "active": true + } + ] + }, + "invicjusz": { + "characters": [ + { + "name": "ElementalEnchant", + "class": "FB", + "level": 76, + "nation": "Procyon", + "active": true + } + ] + }, + "marin": { + "characters": [ + { + "name": "iMarieLaveau", + "class": "DM", + "level": 79, + "nation": "Capella", + "active": true + } + ] + }, + "sean": { + "characters": [ + { + "name": "»No.1«", + "class": "FB", + "level": 79, + "nation": "Capella", + "active": true + }, + { + "name": "«No.1»", + "class": "GL", + "level": 79, + "nation": "Capella", + "active": false + } + ] + } +} \ No newline at end of file diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..5d6d184 --- /dev/null +++ b/data/config.json @@ -0,0 +1,4 @@ +{ + "showLevelInMessages": true, + "showClassInMessages": true +} \ No newline at end of file diff --git a/data/tg-history/2026-06-01-00.json b/data/tg-history/2026-06-01-00.json new file mode 100644 index 0000000..8956b56 --- /dev/null +++ b/data/tg-history/2026-06-01-00.json @@ -0,0 +1,29 @@ +{ + "slot": 0, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "scores": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "pts": 2000, + "submittedAt": "2026-06-01T03:22:25.475Z", + "slot": 0, + "date": "2026-06-01", + "submittedByOfficer": false + } + ] +} \ No newline at end of file diff --git a/data/tg-history/2026-06-01-02.json b/data/tg-history/2026-06-01-02.json new file mode 100644 index 0000000..a8125e1 --- /dev/null +++ b/data/tg-history/2026-06-01-02.json @@ -0,0 +1,29 @@ +{ + "slot": 2, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "scores": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "pts": 100, + "submittedAt": "2026-06-01T03:22:43.115Z", + "slot": 2, + "date": "2026-06-01", + "submittedByOfficer": false + } + ] +} \ No newline at end of file diff --git a/data/tg-history/2026-06-01-04.json b/data/tg-history/2026-06-01-04.json new file mode 100644 index 0000000..5d14a7b --- /dev/null +++ b/data/tg-history/2026-06-01-04.json @@ -0,0 +1,29 @@ +{ + "slot": 4, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "scores": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "pts": 100, + "submittedAt": "2026-06-01T03:22:48.373Z", + "slot": 4, + "date": "2026-06-01", + "submittedByOfficer": false + } + ] +} \ No newline at end of file diff --git a/data/tg-history/2026-06-01-06.json b/data/tg-history/2026-06-01-06.json new file mode 100644 index 0000000..5d474f7 --- /dev/null +++ b/data/tg-history/2026-06-01-06.json @@ -0,0 +1,29 @@ +{ + "slot": 6, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "scores": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "pts": 100, + "submittedAt": "2026-06-01T03:22:54.521Z", + "slot": 6, + "date": "2026-06-01", + "submittedByOfficer": false + } + ] +} \ No newline at end of file diff --git a/data/tg-history/2026-06-01-08.json b/data/tg-history/2026-06-01-08.json new file mode 100644 index 0000000..a73c62a --- /dev/null +++ b/data/tg-history/2026-06-01-08.json @@ -0,0 +1,29 @@ +{ + "slot": 8, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "scores": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "pts": 100, + "submittedAt": "2026-06-01T03:23:03.650Z", + "slot": 8, + "date": "2026-06-01", + "submittedByOfficer": false + } + ] +} \ No newline at end of file diff --git a/data/tg-history/2026-06-01-20.json b/data/tg-history/2026-06-01-20.json new file mode 100644 index 0000000..0f614da --- /dev/null +++ b/data/tg-history/2026-06-01-20.json @@ -0,0 +1,40 @@ +{ + "slot": 20, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "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", + "class": "FB", + "nation": "Procyon", + "pts": 5000, + "submittedAt": "2026-06-01T03:19:12.073Z", + "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 new file mode 100644 index 0000000..d37c85a --- /dev/null +++ b/data/tg-history/2026-06-01-22.json @@ -0,0 +1,29 @@ +{ + "slot": 22, + "date": "2026-06-01", + "confirmed": false, + "nationKD": { + "source": "Procyon", + "capella": { + "k": 0, + "d": 0 + }, + "procyon": { + "k": 0, + "d": 0 + } + }, + "scores": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "pts": 2000, + "submittedAt": "2026-06-01T03:22:14.287Z", + "slot": 22, + "date": "2026-06-01", + "submittedByOfficer": false + } + ] +} \ No newline at end of file diff --git a/data/usermap.json b/data/usermap.json new file mode 100644 index 0000000..ae57a72 --- /dev/null +++ b/data/usermap.json @@ -0,0 +1,13 @@ +{ + "nunoflashy": { + "file": "flash", + "aliases": ["Flash", "El Flasho"] + }, + "staki91": "dey", + "invicjusz": "invicjusz", + "mrsean.": "sean", + "ibenni": "ayana", + "zephyr_74135": "zephyr", + "eat.jim.sleep": "keira", + "mar1n1987": "marin" +} \ No newline at end of file diff --git a/data/wrank.json b/data/wrank.json new file mode 100644 index 0000000..0c2404c --- /dev/null +++ b/data/wrank.json @@ -0,0 +1,16 @@ +{ + "2026-W23": { + "weekKey": "2026-W23", + "entries": { + "capella": [], + "procyon": [] + }, + "scoreIndex": {}, + "bringer": { + "capella": null, + "procyon": null, + "procyonOverride": "flash", + "capellaOverride": "zephyr" + } + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1d8e859 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + tg-bot: + build: + context: /opt/docker/tg-bot-ts + image: tg-bot-ts:latest + container_name: tg-bot-ts + restart: unless-stopped + env_file: + - /opt/docker/tg-bot-ts/.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 diff --git a/messages/emojis.json b/messages/emojis.json new file mode 100644 index 0000000..cf6404d --- /dev/null +++ b/messages/emojis.json @@ -0,0 +1,43 @@ +{ + "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>", + "wrank_up": "", + "wrank_down": "", + "atk": "", + "def": "", + "heal": "", + "wrank_neutral": "", + "wrank_1": "", + "wrank_1_gold": "", + "wrank_2": "", + "wrank_2_gold": "", + "wrank_3": "", + "wrank_3_gold": "", + "wrank_4": "", + "wrank_4_gold": "", + "wrank_5": "", + "wrank_5_gold": "", + "wrank_6": "", + "wrank_6_gold": "", + "wrank_7": "", + "wrank_7_gold": "", + "wrank_8": "", + "wrank_8_gold": "", + "wrank_9": "", + "wrank_9_gold": "", + "wrank_10": "", + "wrank_10_gold": "", + "kd": "", + "score": "", + "rank": "", + "borrowed": "🔗" +} diff --git a/messages/global.json b/messages/global.json new file mode 100644 index 0000000..e9666f0 --- /dev/null +++ b/messages/global.json @@ -0,0 +1,44 @@ +{ + "public": { + "yes": [ + { + "clicks": 1, + "random": true, + "messages": ["{nickname} is in! ⚔️", "Let's go {nickname}!", "{nickname} ready to fight!"], + "days": { + "friday": { "random": true, "messages": ["It's {Day}! {nickname} is in, let's end the week strong! 🎉"] } + }, + "dates": { + "25-12": { "messages": ["Merry Christmas {nickname}! Still showing up! 🎄⚔️"] }, + "01-01": { "messages": ["Happy New Year! {nickname} is in for the first TG of {date_full}! 🎆"] } + } + }, + { "clicks": 5, "messages": ["{nickname} is really committed."] }, + { "clicks": 10, "messages": ["{nickname} is locked in forever."] } + ], + "no": [ + { + "clicks": 1, + "random": true, + "messages": ["{nickname} is sitting this one out... 🪳", "{nickname} roaching out!", "No from {nickname}... 👀"], + "days": { + "friday": { "messages": ["{nickname} skipping TG on a {Day}? Shameful. 🪳"] } + } + }, + { "clicks": 5, "messages": ["{nickname} said no 5 times. Wow."] }, + { "clicks": 10, "messages": ["{nickname} is a certified roach. 🪳"] } + ] + }, + "ephemeral": { + "yes": [ + { "clicks": 1, "random": true, "messages": ["✅ You voted **Yes** for TG tonight!", "✅ Let's go {nickname}!"] }, + { "clicks": 5, "messages": ["✅ Still clicking Yes, we see you {nickname}."] }, + { "clicks": 10, "messages": ["✅ Alright {nickname}, you're locked in for good."] } + ], + "no": [ + { "clicks": 1, "random": true, "messages": ["❌ Roaching out {nickname}?", "❌ Really {nickname}? No?"] }, + { "clicks": 5, "messages": ["❌ Five times no. Really, {nickname}?"] }, + { "clicks": 10, "messages": ["❌ Locked out, absolute coward."] } + ] + } +} diff --git a/messages/users/ayana.json b/messages/users/ayana.json new file mode 100644 index 0000000..aede962 --- /dev/null +++ b/messages/users/ayana.json @@ -0,0 +1,16 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": ["Ayana is in"]}, + { "clicks": 10, "random": true, "messages": ["Ayana..."] } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "Went for a kebab", + "Doesn't give a fuck" + ] + } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/messages/users/dey.json b/messages/users/dey.json new file mode 100644 index 0000000..9eaa719 --- /dev/null +++ b/messages/users/dey.json @@ -0,0 +1,18 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": ["Dey is in"]}, + { "clicks": 2, "random": true, "messages": ["Courageous now, new account afterall"] }, + { "clicks": 10, "random": true, "messages": ["Now you're just asking for it."] } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "Everything's for sale", + "Dey roaching out 🪳", + "Dey said no... shocking" + ] + } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/messages/users/flash.json b/messages/users/flash.json new file mode 100644 index 0000000..c3f1eee --- /dev/null +++ b/messages/users/flash.json @@ -0,0 +1,30 @@ +{ + "public": { + "yes": [ + { + "clicks": 1, + "random": true, + "messages": ["The King has arrived. 👑", "Flash is in, bow down.", "👑 Royalty has entered the raid.","{alias[0]} is in"] + }, + { "clicks": 2, "random": true, "messages": ["Flash? Flash? Flash!!"] } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "Can't come." + ], + "days": { + "sunday": {"messages": ["{alias[0]} has to work again... Wait... on a {Day}?!"]} + } + }, + { "clicks": 2, "random": true, "messages": ["Flash has to work again..."] } + ] + }, + "ephemeral": { + "yes": [ + { "clicks": 1, "messages": ["✅ King is in! 👑"] } + ], + "no": [ + { "clicks": 1, "messages": ["❌ Really Flash? Roaching again, smh."] } + ] + } +} \ No newline at end of file diff --git a/messages/users/invicjusz.json b/messages/users/invicjusz.json new file mode 100644 index 0000000..9df0f70 --- /dev/null +++ b/messages/users/invicjusz.json @@ -0,0 +1,19 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": [ + "Vic is in" + ] + }, + { "clicks": 2, "random": true, "messages": ["Vic is really in"] }, + { "clicks": 10, "random": true, "messages": ["Stop it Vic, you're in."] } + ], + "no": [ + { "clicks": 1, "random": true, "messages": ["Needs to check if the water works"]}, + { "clicks": 2, "random": true, "messages": ["Needs to check if the tap has water"]}, + { "clicks": 3, "random": true, "messages": ["Checking if the water is wet..."]}, + { "clicks": 4, "random": true, "messages": ["The water really is wet!"] } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/messages/users/keira.json b/messages/users/keira.json new file mode 100644 index 0000000..430d965 --- /dev/null +++ b/messages/users/keira.json @@ -0,0 +1,18 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": [ + "Keira is in" + ] + } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "No chasey for capella", + "Says no... going to the gym?" + ] + } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/messages/users/marin.json b/messages/users/marin.json new file mode 100644 index 0000000..637fb12 --- /dev/null +++ b/messages/users/marin.json @@ -0,0 +1,20 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": [ + "Marin is in", + "Marin is in, silence 24/7", + "Marin is in, get your silence pots ready! Wait... they dont exist" + ] + } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "No petrify in this TG", + "Says no" + ] + } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/messages/users/sean.json b/messages/users/sean.json new file mode 100644 index 0000000..0f66d15 --- /dev/null +++ b/messages/users/sean.json @@ -0,0 +1,19 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": ["Sean is in"]}, + { "clicks": 5, "random": true, "messages": ["Sean is probably drunk"] }, + { "clicks": 10, "random": true, "messages": ["Stop it Sean."] } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "Went out drinking" , + "Went out partying" , + "Is drunk", + "Is on vaca— building collection" + ] + } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/messages/users/zephyr.json b/messages/users/zephyr.json new file mode 100644 index 0000000..250b5c2 --- /dev/null +++ b/messages/users/zephyr.json @@ -0,0 +1,21 @@ +{ + "public": { + "yes": [ + { "clicks": 1, "random": true, "messages": [ + "Legend is in", + "Best FA shows up", + "Healmeister reporting for duty", + "Capella MVP is up" + ] + } + ], + "no": [ + { "clicks": 1, "random": true, "messages": [ + "Cannot come, TG lost" , + "Says no... are you okay Zephyr?" + ] + } + ] + }, + "ephemeral": {} +} \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..0b84199 --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node src/index.ts" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9898fcd --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "tg-bot", + "version": "2.0.0", + "description": "Cabal Online TG planning and tracking bot", + "main": "src/index.ts", + "scripts": { + "start": "ts-node src/index.ts", + "dev": "nodemon", + "register": "ts-node src/index.ts --register" + }, + "dependencies": { + "discord.js": "^14.15.3", + "node-cron": "^3.0.3" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/node-cron": "^3.0.0", + "nodemon": "^3.1.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + } +} diff --git a/src/commands/tg.ts b/src/commands/tg.ts new file mode 100644 index 0000000..a1efe26 --- /dev/null +++ b/src/commands/tg.ts @@ -0,0 +1,297 @@ +import { + ChatInputCommandInteraction, + SlashCommandBuilder, + REST, + Routes, +} from "discord.js"; +import { cfg } from "../systems/config"; +import { hasOfficerRole } from "../systems/users"; + +// Poll subcommands +import { handleStart } from "../subcommands/poll/start"; +import { handleLock } from "../subcommands/poll/lock"; +import { handleUnlock } from "../subcommands/poll/unlock"; +import { handleConfirm } from "../subcommands/poll/confirm"; +import { handleStatus } from "../subcommands/poll/status"; +import { handleReload } from "../subcommands/poll/reload"; +import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEphemeral } from "../subcommands/poll/setMessage"; +import { handleInject, handleRemoveVote } from "../subcommands/poll/inject"; +import { handleSeed } from "../subcommands/poll/seed"; +import { handlePurge } from "../subcommands/poll/purge"; + +// Char subcommands (borrow / sharing system) +import { handleCharBorrow } from "../subcommands/char/borrow"; +import { handleCharAccept } from "../subcommands/char/accept"; +import { handleCharDecline } from "../subcommands/char/decline"; +import { handleCharShare, handleCharUnshare } from "../subcommands/char/share"; + +// Score subcommands +import { handleScoreSet } from "../subcommands/score/set"; +import { handleScoreGet } from "../subcommands/score/get"; + +// Rank subcommands +import { handleRankGet } from "../subcommands/rank/get"; +import { handleRankPost } from "../subcommands/rank/post"; + +// Result subcommands +import { handleResultSet } from "../subcommands/result/set"; +import { handleResultView } from "../subcommands/result/view"; +import { handleResultPost } from "../subcommands/result/post"; + +// Bringer subcommands +import { handleBringerSet } from "../subcommands/bringer/set"; +import { handleBringerClear } from "../subcommands/bringer/clear"; + +// Other +import { handleSwitch } from "../subcommands/switch"; +import { handleHistory } from "../subcommands/history"; + +export function buildTgCommand(): SlashCommandBuilder { + const cmd = new SlashCommandBuilder() + .setName("tg") + .setDescription("TG planning and tracking"); + + // ── poll group ───────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .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))) + .addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll") + .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))) + .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))) + .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))) + .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))) + .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))) + .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("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))) + .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")) + ); + + // ── score group ──────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("score") + .setDescription("Score management") + .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))) + .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))) + ); + + // ── rank group ───────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .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))) + .addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)")) + ); + + // ── result group ─────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("result") + .setDescription("TG result management") + .addSubcommand((s) => s.setName("set").setDescription("Set nation K/D (officer only)") + .addStringOption((o) => o.setName("nation").setDescription("Source nation").setRequired(true) + .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) + .addIntegerOption((o) => o.setName("kills").setDescription("Kills").setRequired(true)) + .addIntegerOption((o) => o.setName("deaths").setDescription("Deaths").setRequired(true)) + .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) + .addSubcommand((s) => s.setName("view").setDescription("View result for a slot") + .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) + .addSubcommand((s) => s.setName("post").setDescription("Post result publicly (officer only)") + .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))) + ); + + // ── bringer group ────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("bringer") + .setDescription("Bringer management (officer only)") + .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))) + .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" }))) + ); + + // ── 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)) + ); + + // ── char group ───────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("char") + .setDescription("Character management") + .addSubcommand((s) => s.setName("add").setDescription("Add a character") + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) + .addStringOption((o) => o.setName("class").setDescription("Class").setRequired(true) + .addChoices( + { name: "Blader (BL)", value: "BL" }, + { name: "Force Blader (FB)", value: "FB" }, + { name: "Force Shielder (FS)", value: "FS" }, + { name: "Force Archer (FA)", value: "FA" }, + { name: "Force Gunner (FG)", value: "FG" }, + { name: "Gladiator (GL)", value: "GL" }, + { name: "Dark Mage (DM)", value: "DM" }, + { name: "Wizard (WI)", value: "WI" }, + { name: "Warrior (WA)", value: "WA" }, + )) + .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))) + .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))) + .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))) + .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))) + .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))) + .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))) + .addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request") + .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true))) + .addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request") + .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(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))) + .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))) +); + + // ── history ──────────────────────────────────────────────────────────────── + cmd.addSubcommand((s) => s.setName("history").setDescription("View TG history (officer only)") + .addStringOption((o) => o.setName("date").setDescription("Date (YYYY-MM-DD)").setRequired(false)) + .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) + ); + + return cmd; +} + +export async function handleTgCommand(interaction: ChatInputCommandInteraction): Promise { + 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")); + + // Officer-only commands + const officerOnlyGroups = ["poll", "result", "bringer"]; + const officerOnlySubs = ["history"]; + const officerOnlyRankSubs = ["post"]; + + if (group && officerOnlyGroups.includes(group) && !isOfficer) { + return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true }); + } + if (!group && officerOnlySubs.includes(sub) && !isOfficer) { + return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true }); + } + if (group === "rank" && officerOnlyRankSubs.includes(sub) && !isOfficer) { + return void interaction.reply({ content: "❌ You don't have permission to use this command.", ephemeral: true }); + } + + // Route + if (group === "poll") { + if (sub === "start") return handleStart(interaction); + if (sub === "lock") return handleLock(interaction); + if (sub === "unlock") return handleUnlock(interaction); + if (sub === "confirm") return handleConfirm(interaction); + if (sub === "reload") return handleReload(interaction); + if (sub === "status") return handleStatus(interaction); + if (sub === "set-message") return handleSetMessage(interaction); + if (sub === "clear-message") return handleClearMessage(interaction); + if (sub === "set-ephemeral") return handleSetEphemeral(interaction); + if (sub === "clear-ephemeral") return handleClearEphemeral(interaction); + if (sub === "inject") return handleInject(interaction); + if (sub === "remove-vote") return handleRemoveVote(interaction); + if (sub === "purge") return handlePurge(interaction); + if (sub === "seed") return handleSeed(interaction); + } + if (group === "score") { + if (sub === "set") return handleScoreSet(interaction); + if (sub === "get") return handleScoreGet(interaction); + } + if (group === "rank") { + if (sub === "get") return handleRankGet(interaction); + if (sub === "post") return handleRankPost(interaction); + } + if (group === "result") { + if (sub === "set") return handleResultSet(interaction); + if (sub === "view") return handleResultView(interaction); + if (sub === "post") return handleResultPost(interaction); + } + if (group === "bringer") { + if (sub === "set") return handleBringerSet(interaction); + if (sub === "clear") return handleBringerClear(interaction); + } + if (group === "char") { + if (sub === "add") return handleCharAdd(interaction); + if (sub === "remove") return handleCharRemove(interaction); + if (sub === "set-active") return handleCharSetActive(interaction); + if (sub === "set-nation") return handleCharSetNation(interaction); + if (sub === "set-stats") return handleCharSetStats(interaction); + if (sub === "borrow") return handleCharBorrow(interaction); + if (sub === "accept") return handleCharAccept(interaction); + if (sub === "decline") return handleCharDecline(interaction); + if (sub === "share") return handleCharShare(interaction); + if (sub === "unshare") return handleCharUnshare(interaction); + } + if (!group && sub === "switch") return handleSwitch(interaction); + if (!group && sub === "history") return handleHistory(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/commands/tgConfig.ts b/src/commands/tgConfig.ts new file mode 100644 index 0000000..017a301 --- /dev/null +++ b/src/commands/tgConfig.ts @@ -0,0 +1,211 @@ +import { ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; +import { cfg, setCfg, resetCfg } from "../systems/config"; +import { hasOfficerRole } from "../systems/users"; +import { replyAndDelete } from "../utils"; +import { Nation } from "../types"; + +export function buildTgConfigCommand(): SlashCommandBuilder { + const cmd = new SlashCommandBuilder() + .setName("tg-config") + .setDescription("Configure the TG bot"); + + const strOpt = (name: string, desc: string, req = true) => + (o: any) => o.setName(name).setDescription(desc).setRequired(req); + + const decisionOpt = (o: any) => o.setName("decision").setDescription("yes or no").setRequired(true) + .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }); + + const nationOpt = (o: any) => o.setName("nation").setDescription("Nation").setRequired(true) + .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }); + + const roleOpt = strOpt("role", "Role name"); + const rolesOpt = strOpt("roles", "Comma-separated role names"); + + // ── message group ────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("message") + .setDescription("Configure bot messages") + .addSubcommand((s) => s.setName("set-lock").setDescription("Set default lock message") + .addStringOption(strOpt("message", "New lock message"))) + .addSubcommand((s) => s.setName("reset-lock").setDescription("Reset lock message to default")) + .addSubcommand((s) => s.setName("set-confirm").setDescription("Set default confirm message") + .addStringOption(decisionOpt) + .addStringOption(strOpt("message", "New confirm message"))) + .addSubcommand((s) => s.setName("reset-confirm").setDescription("Reset confirm message to default") + .addStringOption(decisionOpt)) + ); + + // ── roles group ──────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("roles") + .setDescription("Configure bot roles") + .addSubcommand((s) => s.setName("set-officer").setDescription("Set officer roles (comma-separated)").addStringOption(rolesOpt)) + .addSubcommand((s) => s.setName("add-officer").setDescription("Add an officer role").addStringOption(roleOpt)) + .addSubcommand((s) => s.setName("remove-officer").setDescription("Remove an officer role").addStringOption(roleOpt)) + .addSubcommand((s) => s.setName("reset-officer").setDescription("Reset officer roles to default")) + .addSubcommand((s) => s.setName("set-config").setDescription("Set config roles (comma-separated)").addStringOption(rolesOpt)) + .addSubcommand((s) => s.setName("add-config").setDescription("Add a config role").addStringOption(roleOpt)) + .addSubcommand((s) => s.setName("remove-config").setDescription("Remove a config role").addStringOption(roleOpt)) + .addSubcommand((s) => s.setName("reset-config").setDescription("Reset config roles to default")) + .addSubcommand((s) => s.setName("set-tag").setDescription("Set tag roles (comma-separated)").addStringOption(rolesOpt)) + .addSubcommand((s) => s.setName("add-tag").setDescription("Add a tag role").addStringOption(roleOpt)) + .addSubcommand((s) => s.setName("remove-tag").setDescription("Remove a tag role").addStringOption(roleOpt)) + .addSubcommand((s) => s.setName("reset-tag").setDescription("Reset tag roles to default")) + ); + + // ── channel group ────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("channel") + .setDescription("Configure bot channels") + .addSubcommand((s) => s.setName("set-poll").setDescription("Set poll channel") + .addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true))) + .addSubcommand((s) => s.setName("set-results").setDescription("Set results channel") + .addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true))) + .addSubcommand((s) => s.setName("set-score").setDescription("Set score channel") + .addChannelOption((o) => o.setName("channel").setDescription("Channel").setRequired(true))) + ); + + // ── slot group ───────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("slot") + .setDescription("Configure TG slots") + .addSubcommand((s) => s.setName("add").setDescription("Add a TG slot") + .addIntegerOption((o) => o.setName("hour").setDescription("TG hour (0-23)").setRequired(true)) + .addStringOption(strOpt("poll_opens", "Poll open time e.g. 10:00"))) + .addSubcommand((s) => s.setName("remove").setDescription("Remove a TG slot") + .addIntegerOption((o) => o.setName("hour").setDescription("TG hour").setRequired(true))) + ); + + // ── wrank group ──────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("wrank") + .setDescription("Configure W.Rank settings") + .addSubcommand((s) => s.setName("set-goal").setDescription("Set W.Rank TG goal") + .addIntegerOption((o) => o.setName("goal").setDescription("Number of TGs").setRequired(true))) + .addSubcommand((s) => s.setName("set-post-on-reset").setDescription("Post leaderboard on weekly reset?") + .addBooleanOption((o) => o.setName("enabled").setDescription("true/false").setRequired(true))) + ); + + // ── tg group ─────────────────────────────────────────────────────────────── + cmd.addSubcommandGroup((g) => g + .setName("tg") + .setDescription("Configure TG settings") + .addSubcommand((s) => s.setName("set-score-window").setDescription("Set score submission window (hours)") + .addNumberOption((o) => o.setName("hours").setDescription("Hours").setRequired(true))) + .addSubcommand((s) => s.setName("set-duration").setDescription("Set TG duration (minutes)") + .addIntegerOption((o) => o.setName("minutes").setDescription("Minutes").setRequired(true))) + .addSubcommand((s) => s.setName("set-no-display").setDescription("Where to show No voters") + .addStringOption((o) => o.setName("mode").setDescription("inline or messages").setRequired(true) + .addChoices({ name: "Inline under nation", value: "inline" }, { name: "Messages section only", value: "messages" }))) + .addSubcommand((s) => s.setName("set-nation-source").setDescription("Set source of truth nation") + .addStringOption(nationOpt)) + ); + + return cmd; +} + +export async function handleTgConfigCommand(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, cfg("configRoles"))) { + return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); + } + + const group = interaction.options.getSubcommandGroup(true); + const sub = interaction.options.getSubcommand(); + + const roleSubcommand = (cfgKey: "officerRoles" | "configRoles" | "tagRoles", action: string) => { + if (action === "set") { + const roles = interaction.options.getString("roles", true).split(",").map((r) => r.trim()).filter(Boolean); + setCfg(cfgKey, roles); + return void replyAndDelete(interaction, `✅ Roles updated: ${roles.join(", ")}`); + } + if (action === "add") { + const role = interaction.options.getString("role", true).trim(); + const roles = [...new Set([...cfg(cfgKey), role])]; + setCfg(cfgKey, roles); + return void replyAndDelete(interaction, `✅ Added **${role}**. Current: ${roles.join(", ")}`); + } + if (action === "remove") { + const role = interaction.options.getString("role", true).trim(); + const roles = cfg(cfgKey).filter((r: string) => r !== role); + setCfg(cfgKey, roles); + return void replyAndDelete(interaction, `✅ Removed **${role}**. Current: ${roles.join(", ")}`); + } + if (action === "reset") { + resetCfg(cfgKey); + return void replyAndDelete(interaction, `✅ Roles reset to default.`); + } + }; + + // ── message ──────────────────────────────────────────────────────────────── + if (group === "message") { + if (sub === "set-lock") { setCfg("lockMessage", interaction.options.getString("message", true)); return void replyAndDelete(interaction, "✅ Lock message updated."); } + if (sub === "reset-lock") { resetCfg("lockMessage"); return void replyAndDelete(interaction, "✅ Lock message reset."); } + if (sub === "set-confirm") { + const d = interaction.options.getString("decision", true); + setCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage", interaction.options.getString("message", true)); + return void replyAndDelete(interaction, `✅ Confirm ${d} message updated.`); + } + if (sub === "reset-confirm") { + const d = interaction.options.getString("decision", true); + resetCfg(d === "yes" ? "confirmYesMessage" : "confirmNoMessage"); + return void replyAndDelete(interaction, `✅ Confirm ${d} message reset.`); + } + } + + // ── roles ────────────────────────────────────────────────────────────────── + if (group === "roles") { + if (sub === "set-officer") return roleSubcommand("officerRoles", "set"); + if (sub === "add-officer") return roleSubcommand("officerRoles", "add"); + if (sub === "remove-officer") return roleSubcommand("officerRoles", "remove"); + if (sub === "reset-officer") return roleSubcommand("officerRoles", "reset"); + if (sub === "set-config") return roleSubcommand("configRoles", "set"); + if (sub === "add-config") return roleSubcommand("configRoles", "add"); + if (sub === "remove-config") return roleSubcommand("configRoles", "remove"); + if (sub === "reset-config") return roleSubcommand("configRoles", "reset"); + if (sub === "set-tag") return roleSubcommand("tagRoles", "set"); + if (sub === "add-tag") return roleSubcommand("tagRoles", "add"); + if (sub === "remove-tag") return roleSubcommand("tagRoles", "remove"); + if (sub === "reset-tag") return roleSubcommand("tagRoles", "reset"); + } + + // ── channel ──────────────────────────────────────────────────────────────── + if (group === "channel") { + if (sub === "set-poll") { setCfg("pollChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Poll channel updated."); } + if (sub === "set-results") { setCfg("resultsChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Results channel updated."); } + if (sub === "set-score") { setCfg("scoreChannelId", interaction.options.getChannel("channel", true).id); return void replyAndDelete(interaction, "✅ Score channel updated."); } + } + + // ── slot ─────────────────────────────────────────────────────────────────── + if (group === "slot") { + if (sub === "add") { + const hour = interaction.options.getInteger("hour", true); + const pollOpens = interaction.options.getString("poll_opens", true); + const slots = cfg("slots"); + if (slots.some((s) => s.tgHour === hour)) return void replyAndDelete(interaction, `❌ Slot ${hour}:00 already exists.`); + slots.push({ tgHour: hour, pollOpens, closesAfter: cfg("tgDurationMinutes"), active: true }); + setCfg("slots", slots); + return void replyAndDelete(interaction, `✅ Slot ${hour}:00 added (poll opens at ${pollOpens}).`); + } + if (sub === "remove") { + const hour = interaction.options.getInteger("hour", true); + const slots = cfg("slots").filter((s) => s.tgHour !== hour); + setCfg("slots", slots); + return void replyAndDelete(interaction, `✅ Slot ${hour}:00 removed.`); + } + } + + // ── wrank ────────────────────────────────────────────────────────────────── + if (group === "wrank") { + if (sub === "set-goal") { setCfg("wRankGoal", interaction.options.getInteger("goal", true)); return void replyAndDelete(interaction, "✅ W.Rank goal updated."); } + if (sub === "set-post-on-reset") { setCfg("wRankPostOnReset", interaction.options.getBoolean("enabled", true)); return void replyAndDelete(interaction, "✅ W.Rank post on reset updated."); } + } + + // ── tg ───────────────────────────────────────────────────────────────────── + if (group === "tg") { + if (sub === "set-score-window") { setCfg("scoreWindowHours", interaction.options.getNumber("hours", true)); return void replyAndDelete(interaction, "✅ Score window updated."); } + if (sub === "set-duration") { setCfg("tgDurationMinutes", interaction.options.getInteger("minutes", true)); return void replyAndDelete(interaction, "✅ TG duration updated."); } + if (sub === "set-no-display") { setCfg("showNoInNationField" as any, interaction.options.getString("mode", true) === "inline"); return void replyAndDelete(interaction, "✅ No voter display updated."); } + if (sub === "set-nation-source"){ setCfg("nationSource", interaction.options.getString("nation", true) as Nation); return void replyAndDelete(interaction, "✅ Nation source updated."); } + } +} \ No newline at end of file diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts new file mode 100644 index 0000000..af44df7 --- /dev/null +++ b/src/handlers/buttons.ts @@ -0,0 +1,109 @@ +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"; + +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 clickCounts = new Map(); + +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(); + + const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; + if (slot === undefined) return; + + const state = polls.get(slot)!; + if (state.locked || state.confirmed !== null) return; + + const userId = interaction.user.id; + const member = await interaction.guild!.members.fetch(userId); + const user = await resolveUser(member); + const votedYes = interaction.customId === "tg_yes"; + const now = nowFormatted(); + + // Check nation — block if no nation + const nation = resolveNation(member, user.usermapKey); + if (!nation) { + if (EPHEMERAL_ENABLED) { + const reply = await interaction.followUp({ content: "❌ You must be in Capella or Procyon to vote.", ephemeral: true }); + if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + } + return; + } + + // Click tracking + if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 }); + const clicks = clickCounts.get(userId)!; + + 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) 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); + + const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no") + ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname); + + const baseEntry = createVoteEntry(userId, member, user.usermapKey, user.discordUsername); + + if (votedYes) { + const previousNo = state.no.get(userId); + state.no.delete(userId); + state.yes.set(userId, { + ...baseEntry, + votedAt: now, + previousNoAt: previousNo?.votedAt, + publicMessage: publicMsg ?? undefined, + }); + } else { + const previousYes = state.yes.get(userId); + state.yes.delete(userId); + state.no.set(userId, { + ...baseEntry, + votedAt: now, + previousYesAt: previousYes?.votedAt, + publicMessage: publicMsg ?? undefined, + }); + } + + const locked = clickCount >= 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 channel = interaction.channel as TextChannel; + await updatePollMessage(channel, slot); +} + +export function resetClickCounts(): void { + clickCounts.clear(); +} \ No newline at end of file diff --git a/src/handlers/interactions.ts b/src/handlers/interactions.ts new file mode 100644 index 0000000..5a2179d --- /dev/null +++ b/src/handlers/interactions.ts @@ -0,0 +1,42 @@ +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"; + +export async function handleInteraction(interaction: Interaction): Promise { + try { + if (interaction.isButton()) { + const btn = interaction as ButtonInteraction; + + // Borrow accept/decline buttons from DM + 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); + } + + return await handleButton(btn); + } + + if (interaction.isChatInputCommand()) { + const cmd = interaction as ChatInputCommandInteraction; + if (cmd.commandName === "tg") await handleTgCommand(cmd); + if (cmd.commandName === "tg-config") await handleTgConfigCommand(cmd); + } + } catch (err) { + console.error("Interaction error:", err); + try { + const msg = { content: "❌ An error occurred.", ephemeral: true }; + if ((interaction as any).replied || (interaction as any).deferred) { + await (interaction as any).followUp(msg); + } else { + await (interaction as any).reply(msg); + } + } catch {} + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..50bb28b --- /dev/null +++ b/src/index.ts @@ -0,0 +1,72 @@ +import { Client, GatewayIntentBits, REST, Routes } from "discord.js"; +import { loadConfig, cfg } from "./systems/config"; +import { loadMessages } from "./systems/messages"; +import { loadEmojis } from "./systems/emojis"; +import { loadCharacters } from "./systems/characters"; +import { loadWRank } from "./systems/wrank"; +import { scheduleSlots } from "./systems/slots"; +import { postPoll, polls } from "./systems/poll"; +import { handleInteraction } from "./handlers/interactions"; +import { buildTgCommand } from "./commands/tg"; +import { buildTgConfigCommand } from "./commands/tgConfig"; +import { TGSlot } from "./types"; + +const TOKEN = process.env.DISCORD_TOKEN!; +const CLIENT_ID = process.env.CLIENT_ID!; +const GUILD_ID = process.env.GUILD_ID!; + +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers], +}); + +async function registerCommands(): Promise { + const rest = new REST({ version: "10" }).setToken(TOKEN); + await rest.put(Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID), { + body: [buildTgCommand().toJSON(), buildTgConfigCommand().toJSON()], + }); + console.log("Slash commands registered."); +} + +async function onPollOpen(slot: TGSlot): Promise { + const channelId = cfg("pollChannelId"); + const channel = await client.channels.fetch(channelId) as any; + if (!channel) return console.error("Poll channel not found."); + await postPoll(channel, slot); +} + +async function onPollClose(slot: TGSlot): Promise { + const state = polls.get(slot.tgHour); + if (!state) return; + state.locked = true; + const channelId = cfg("pollChannelId"); + const channel = await client.channels.fetch(channelId) as any; + if (!channel) return; + const { updatePollMessage } = require("./systems/poll"); + await updatePollMessage(channel, slot.tgHour); + console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`); +} + +client.on("interactionCreate", handleInteraction); + +client.once("clientReady", async () => { + console.log(`Logged in as ${client.user!.tag}`); + + // Load all data + loadConfig(); + loadMessages(); + loadEmojis(); + loadCharacters(); + loadWRank(); + + // Register commands if --register flag passed + if (process.argv.includes("--register")) { + await registerCommands(); + } + + // Schedule slots + scheduleSlots(client, onPollOpen, onPollClose); + + console.log("Bot ready."); +}); + +client.login(TOKEN); diff --git a/src/subcommands/bringer/clear.ts b/src/subcommands/bringer/clear.ts new file mode 100644 index 0000000..caf04db --- /dev/null +++ b/src/subcommands/bringer/clear.ts @@ -0,0 +1,10 @@ +import { ChatInputCommandInteraction } from "discord.js"; +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}**.`); +} diff --git a/src/subcommands/bringer/set.ts b/src/subcommands/bringer/set.ts new file mode 100644 index 0000000..5e81fd1 --- /dev/null +++ b/src/subcommands/bringer/set.ts @@ -0,0 +1,12 @@ +import { ChatInputCommandInteraction } from "discord.js"; +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); + + setBringerOverride(nation, usermapKey); + return void replyAndDelete(interaction, `✅ **${usermapKey}** set as ${nation === "Capella" ? "Luminous" : "Storm"} Bringer for this week.`); +} diff --git a/src/subcommands/char/accept.ts b/src/subcommands/char/accept.ts new file mode 100644 index 0000000..22dfb6f --- /dev/null +++ b/src/subcommands/char/accept.ts @@ -0,0 +1,68 @@ +import { ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { getCharacterByName } from "../../systems/characters"; +import { getPendingRequest, removePendingRequest, setSessionBorrow, updateBorrowDM } from "../../systems/borrow"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; + +export async function handleCharAccept(interaction: ChatInputCommandInteraction): Promise { + const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); + const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username); + const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file; + if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const requesterKey = interaction.options.getString("name", true); + await acceptBorrow(interaction, ownerKey, requesterKey); +} + +export async function handleBorrowAcceptButton(interaction: ButtonInteraction, ownerKey: string, requesterKey: string): Promise { + await acceptBorrow(interaction, ownerKey, requesterKey); +} + +async function acceptBorrow( + interaction: ChatInputCommandInteraction | ButtonInteraction, + ownerKey: string, + requesterKey: string +): Promise { + const request = getPendingRequest(ownerKey, requesterKey); + if (!request) { + const msg = "❌ No pending borrow request found."; + if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true }); + return void replyAndDelete(interaction as ChatInputCommandInteraction, msg); + } + + const char = getCharacterByName(ownerKey, request.charName); + if (!char) { + const msg = `❌ Character **«${request.charName}»** no longer exists.`; + if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true }); + return void replyAndDelete(interaction as ChatInputCommandInteraction, msg); + } + + setSessionBorrow(requesterKey, ownerKey, request.charName); + removePendingRequest(ownerKey, requesterKey); + await updateBorrowDM(interaction.client, ownerKey, requesterKey, true); + + // Update poll embed if requester has already voted + const slot = [...polls.keys()][0]; + if (slot !== undefined) { + const state = polls.get(slot)!; + for (const map of [state.yes, state.no]) { + for (const [, entry] of map) { + if (entry.usermapKey === requesterKey) { + entry.characterName = char.name; + entry.characterClass = char.class; + entry.characterLevel = char.level; + entry.characterNation = char.nation; + } + } + } + try { + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot); + } catch {} + } + + const msg = `✅ Accepted — **${requesterKey}** can now use **«${request.charName}»** for this session.`; + if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true }); + return void replyAndDelete(interaction as ChatInputCommandInteraction, msg); +} \ No newline at end of file diff --git a/src/subcommands/char/add.ts b/src/subcommands/char/add.ts new file mode 100644 index 0000000..d7c71dc --- /dev/null +++ b/src/subcommands/char/add.ts @@ -0,0 +1,32 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { addCharacter } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; +import { ClassKey, Nation } from "../../types"; + +export async function handleCharAdd(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 charName = interaction.options.getString("char_name", true); + const cls = interaction.options.getString("class", true) as ClassKey; + const level = interaction.options.getInteger("level", true); + const nation = interaction.options.getString("nation", true) as Nation; + + let usermapKey: string | null; + if (nameArg) { + if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + + const added = addCharacter(usermapKey, { 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 new file mode 100644 index 0000000..f0a00a5 --- /dev/null +++ b/src/subcommands/char/borrow.ts @@ -0,0 +1,94 @@ +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 handleCharBorrow(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const requester = await resolveUser(member); + + // Args: owner, charname, [username] (officer only — grants directly) + const ownerArg = interaction.options.getString("owner", true); + const charName = interaction.options.getString("char_name", true); + const targetArg = interaction.options.getString("name"); // officer: grant to this user + + if (targetArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly."); + } + + const requesterKey = targetArg ?? requester.usermapKey; + if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const char = getCharacterByName(ownerArg, charName); + if (!char) return void replyAndDelete(interaction, `❌ Character **«${charName}»** not found for **${ownerArg}**.`); + + // Already has access? + if (canUseCharacter(requesterKey, ownerArg, charName)) { + return void replyAndDelete(interaction, `❌ **${requesterKey}** already has access to **«${charName}»**.`); + } + + // Officer bypasses request — grant directly + if (isOfficer && targetArg) { + setSessionBorrow(requesterKey, ownerArg, charName); + + // Update poll if the user has already voted + const slot = [...polls.keys()][0]; + if (slot !== undefined) { + const state = polls.get(slot)!; + 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) { + entry.characterName = char.name; + entry.characterClass = char.class; + entry.characterLevel = char.level; + entry.characterNation = char.nation; + entry.publicMessage = undefined; + } + } + await updatePollMessage(channel, slot); + } + + return void replyAndDelete(interaction, `✅ **${requesterKey}** can now borrow **«${charName}»** for this session.`); + } + + // Regular player — send request to owner + addPendingRequest({ + requesterKey, + ownerKey: ownerArg, + charName, + requestedAt: Date.now(), + }); + + // Find owner's Discord ID from guild members + // We need to reverse-lookup: find the guild member whose discord username maps to ownerArg + const guild = interaction.guild!; + await guild.members.fetch(); + const ownerMember = guild.members.cache.find((m) => { + const entry = (require("../../systems/messages") as any).getUsermapEntry(m.user.username); + return entry?.file === ownerArg || entry === ownerArg; + }); + + if (!ownerMember) { + return void replyAndDelete(interaction, `✅ Borrow request sent — but **${ownerArg}** is not currently in the server to be notified.`); + } + + const fallbackChannel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await sendBorrowRequestDM( + interaction.client, + ownerMember.user.id, + requester.displayName, + ownerArg, + requesterKey, + char.name, + char.class, + char.level, + fallbackChannel + ); + + return void replyAndDelete(interaction, `✅ Borrow request sent to **${ownerArg}** for **«${charName}»**.`); +} \ No newline at end of file diff --git a/src/subcommands/char/decline.ts b/src/subcommands/char/decline.ts new file mode 100644 index 0000000..aff3c4b --- /dev/null +++ b/src/subcommands/char/decline.ts @@ -0,0 +1,37 @@ +import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; +import { getPendingRequest, removePendingRequest, updateBorrowDM } from "../../systems/borrow"; +import { replyAndDelete } from "../../utils"; + +export async function handleCharDecline(interaction: ChatInputCommandInteraction): Promise { + const ownerMember = await interaction.guild!.members.fetch(interaction.user.id); + const ownerEntry = (require("../../systems/messages") as any).getUsermapEntry(ownerMember.user.username); + const ownerKey = typeof ownerEntry === "string" ? ownerEntry : ownerEntry?.file; + if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const requesterKey = interaction.options.getString("name", true); + await declineBorrow(interaction, ownerKey, requesterKey); +} + +export async function handleBorrowDeclineButton(interaction: ButtonInteraction, ownerKey: string, requesterKey: string): Promise { + await declineBorrow(interaction, ownerKey, requesterKey); +} + +async function declineBorrow( + interaction: ChatInputCommandInteraction | ButtonInteraction, + ownerKey: string, + requesterKey: string +): Promise { + const request = getPendingRequest(ownerKey, requesterKey); + if (!request) { + const msg = "❌ No pending borrow request found."; + if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true }); + return void replyAndDelete(interaction as ChatInputCommandInteraction, msg); + } + + removePendingRequest(ownerKey, requesterKey); + await updateBorrowDM(interaction.client, ownerKey, requesterKey, false); + + const msg = `❌ Borrow request for **«${request.charName}»** from **${requesterKey}** declined.`; + if (interaction.isButton()) return void interaction.reply({ content: msg, ephemeral: true }); + return void replyAndDelete(interaction as ChatInputCommandInteraction, msg); +} \ No newline at end of file diff --git a/src/subcommands/char/remove.ts b/src/subcommands/char/remove.ts new file mode 100644 index 0000000..f15f9ba --- /dev/null +++ b/src/subcommands/char/remove.ts @@ -0,0 +1,28 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { removeCharacter } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; + +export async function handleCharRemove(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 charName = interaction.options.getString("char_name", true); + + let usermapKey: string | null; + if (nameArg) { + if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const removed = removeCharacter(usermapKey, 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 new file mode 100644 index 0000000..19dd25a --- /dev/null +++ b/src/subcommands/char/setActive.ts @@ -0,0 +1,55 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { setActiveCharacter } from "../../systems/characters"; +import { setSessionBorrow } from "../../systems/borrow"; +import { replyAndDelete } from "../../utils"; +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; 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; + const char = data.characters?.find( + (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey) + ); + if (char) return { ownerKey, charName: char.name }; + } + } catch {} + return null; +} + +export async function handleCharSetActive(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 charName = interaction.options.getString("char_name", true); + + let usermapKey: string | null; + if (nameArg) { + if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + // Try own characters first + const set = setActiveCharacter(usermapKey, charName); + if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`); + + // Fall back to shared characters + const shared = findSharedChar(usermapKey, charName); + if (shared) { + setSessionBorrow(usermapKey, shared.ownerKey, shared.charName); + return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`); + } + + return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); +} \ No newline at end of file diff --git a/src/subcommands/char/setNation.ts b/src/subcommands/char/setNation.ts new file mode 100644 index 0000000..fdbb447 --- /dev/null +++ b/src/subcommands/char/setNation.ts @@ -0,0 +1,33 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { setCharacterNation, getActiveCharacter } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; +import { Nation } from "../../types"; + +export async function handleCharSetNation(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 nation = interaction.options.getString("nation", true) as Nation; + const charName = interaction.options.getString("char_name"); // optional, defaults to active + + let usermapKey: string | null; + if (nameArg) { + if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + + const targetName = charName ?? getActiveCharacter(usermapKey)?.name; + if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); + + const set = setCharacterNation(usermapKey, 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 new file mode 100644 index 0000000..57a8038 --- /dev/null +++ b/src/subcommands/char/setStats.ts @@ -0,0 +1,34 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { setCharacterStats, getActiveCharacter } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; + +export async function handleCharSetStats(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 charName = interaction.options.getString("char_name"); + const atk = interaction.options.getInteger("atk") ?? undefined; + const def = interaction.options.getInteger("def") ?? undefined; + const heal = interaction.options.getInteger("heal") ?? undefined; + + let usermapKey: string | null; + if (nameArg) { + if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + + const targetName = charName ?? getActiveCharacter(usermapKey)?.name; + if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); + + const set = setCharacterStats(usermapKey, 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 new file mode 100644 index 0000000..ba72a0e --- /dev/null +++ b/src/subcommands/char/share.ts @@ -0,0 +1,82 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { getCharacterByName } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; +import fs from "fs"; +import path from "path"; + +const CHARS_PATH = path.join(__dirname, "../../../data/characters.json"); + +function saveCharacters(chars: any): void { + fs.writeFileSync(CHARS_PATH, JSON.stringify(chars, null, 2)); +} + +function loadRawChars(): any { + return JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); +} + +export async function handleCharShare(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const user = await resolveUser(member); + + const ownerArg = interaction.options.getString("owner"); + const charName = interaction.options.getString("char_name", true); + const targetKey = interaction.options.getString("name", true); + + if (ownerArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can share other players' characters."); + } + + const ownerKey = ownerArg ?? user.usermapKey; + if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const char = getCharacterByName(ownerKey, charName); + if (!char) return void replyAndDelete(interaction, `❌ Character **«${charName}»** not found.`); + + if (char.sharedWith?.includes(targetKey)) { + return void replyAndDelete(interaction, `❌ **${targetKey}** already has access to **«${charName}»**.`); + } + + // Write directly to characters.json + const raw = loadRawChars(); + const charEntry = raw[ownerKey]?.characters?.find((c: any) => c.name.toLowerCase() === charName.toLowerCase()); + if (!charEntry) return void replyAndDelete(interaction, `❌ Character not found in data.`); + + if (!charEntry.sharedWith) charEntry.sharedWith = []; + charEntry.sharedWith.push(targetKey); + saveCharacters(raw); + + return void replyAndDelete(interaction, `✅ **«${charName}»** is now permanently shared with **${targetKey}**.`); +} + +export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const user = await resolveUser(member); + + const ownerArg = interaction.options.getString("owner"); + const charName = interaction.options.getString("char_name", true); + const targetKey = interaction.options.getString("name", true); + + if (ownerArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares."); + } + + const ownerKey = ownerArg ?? user.usermapKey; + if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const raw = loadRawChars(); + const charEntry = raw[ownerKey]?.characters?.find((c: any) => c.name.toLowerCase() === charName.toLowerCase()); + if (!charEntry) return void replyAndDelete(interaction, `❌ Character **«${charName}»** not found.`); + + if (!charEntry.sharedWith?.includes(targetKey)) { + return void replyAndDelete(interaction, `❌ **${targetKey}** does not have access to **«${charName}»**.`); + } + + charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey); + saveCharacters(raw); + + return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`); +} \ No newline at end of file diff --git a/src/subcommands/history.ts b/src/subcommands/history.ts new file mode 100644 index 0000000..13c6eeb --- /dev/null +++ b/src/subcommands/history.ts @@ -0,0 +1,34 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { loadResult, listRecentResults } from "../systems/history"; +import { normalizeSlot } from "../systems/scores"; +import { replyAndDelete } from "../utils"; + +export async function handleHistory(interaction: ChatInputCommandInteraction): Promise { + const dateArg = interaction.options.getString("date"); + const slotArg = interaction.options.getString("slot"); + + if (dateArg && slotArg) { + const slot = normalizeSlot(slotArg); + if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); + + const result = loadResult(dateArg, slot); + if (!result) return void replyAndDelete(interaction, `❌ No result found for ${dateArg} ${slot}:00.`); + + const kd = result.nationKD; + const lines = [ + `**TG ${slot}:00 — ${result.date}**`, + `Capella: ${kd.capella.k}K/${kd.capella.d}D | Procyon: ${kd.procyon.k}K/${kd.procyon.d}D`, + `Scores: ${result.scores.length} submitted`, + ].join("\n"); + return void replyAndDelete(interaction, lines); + } + + const recent = listRecentResults(10); + if (recent.length === 0) return void replyAndDelete(interaction, "❌ No TG history found."); + + const lines = recent.map((r) => + `**${r.date} ${r.slot}:00** — Cap: ${r.nationKD.capella.k}K/${r.nationKD.capella.d}D | Pro: ${r.nationKD.procyon.k}K/${r.nationKD.procyon.d}D` + ).join("\n"); + + return void replyAndDelete(interaction, `**Recent TG History:**\n${lines}`); +} diff --git a/src/subcommands/poll/confirm.ts b/src/subcommands/poll/confirm.ts new file mode 100644 index 0000000..7a36b18 --- /dev/null +++ b/src/subcommands/poll/confirm.ts @@ -0,0 +1,46 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg, setCfg, resetCfg } from "../../systems/config"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; +import { Nation } from "../../types"; + +export async function handleConfirm(interaction: ChatInputCommandInteraction): Promise { + const decision = interaction.options.getString("decision", true) as "yes" | "no"; + const oneTimeMsg = interaction.options.getString("message") ?? null; + const doTag = interaction.options.getBoolean("tag") ?? false; + + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); + + const state = polls.get(slot)!; + state.confirmed = decision; + state.locked = true; + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + + if (oneTimeMsg) { + const cfgKey = decision === "yes" ? "confirmYesMessage" : "confirmNoMessage"; + const saved = cfg(cfgKey); + setCfg(cfgKey, oneTimeMsg); + await updatePollMessage(channel, slot); + if (saved !== undefined) setCfg(cfgKey, saved); + else resetCfg(cfgKey); + } else { + await updatePollMessage(channel, slot); + } + + if (doTag) await tagRoles(channel, interaction); + return void replyAndDelete(interaction, `${decision === "yes" ? "✅" : "❌"} TG confirmed as **${decision}**.`); +} + +async function tagRoles(channel: TextChannel, interaction: ChatInputCommandInteraction): Promise { + const roles = cfg("tagRoles"); + const guild = interaction.guild!; + await guild.roles.fetch(); + const mentions = roles + .map((name) => guild.roles.cache.find((r) => r.name === name)) + .filter(Boolean) + .map((r) => `<@&${r!.id}>`) + .join(" "); + if (mentions) await channel.send(mentions); +} diff --git a/src/subcommands/poll/inject.ts b/src/subcommands/poll/inject.ts new file mode 100644 index 0000000..9606327 --- /dev/null +++ b/src/subcommands/poll/inject.ts @@ -0,0 +1,80 @@ +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 { 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 voteType = interaction.options.getString("vote_type", true) as "yes" | "no"; + + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); + + const state = polls.get(slot)!; + if (state.locked || state.confirmed !== null) { + 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}**.`); + + // Use a synthetic userId based on usermapKey to avoid collisions + const syntheticId = `injected:${usermapKey}`; + const now = nowFormatted(); + + const publicMsg = resolveMessage("public", voteType, 1, usermapKey, null, null); + + const entry: VoteEntry = { + usermapKey, + displayName: char.name, + characterName: char.name, + characterClass: char.class, + characterLevel: char.level, + characterNation: char.nation, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }; + + if (voteType === "yes") { + state.no.delete(syntheticId); + state.yes.set(syntheticId, entry); + } else { + state.yes.delete(syntheticId); + state.no.set(syntheticId, entry); + } + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot); + return void replyAndDelete(interaction, `✅ Injected **${usermapKey}** as **${voteType}**.`); +} + +export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise { + const usermapKey = 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}`; + + // Also try removing real votes by scanning for usermapKey + let removed = false; + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + if (entry.usermapKey === usermapKey || id === syntheticId) { + state.yes.delete(id); + state.no.delete(id); + removed = true; + } + } + + if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${usermapKey}**.`); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot); + return void replyAndDelete(interaction, `✅ Vote removed for **${usermapKey}**.`); +} \ No newline at end of file diff --git a/src/subcommands/poll/lock.ts b/src/subcommands/poll/lock.ts new file mode 100644 index 0000000..a92b03a --- /dev/null +++ b/src/subcommands/poll/lock.ts @@ -0,0 +1,21 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; + +export async function handleLock(interaction: ChatInputCommandInteraction): Promise { + const oneTimeMsg = interaction.options.getString("message") ?? undefined; + const slot = getActiveSlot(); + if (!slot) return void replyAndDelete(interaction, "❌ No active poll found."); + + const state = polls.get(slot)!; + state.locked = true; + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot, oneTimeMsg); + return void replyAndDelete(interaction, "🔒 Poll locked."); +} + +function getActiveSlot(): number | undefined { + return [...polls.keys()][0]; +} diff --git a/src/subcommands/poll/purge.ts b/src/subcommands/poll/purge.ts new file mode 100644 index 0000000..18ae701 --- /dev/null +++ b/src/subcommands/poll/purge.ts @@ -0,0 +1,32 @@ +import { ChatInputCommandInteraction, TextChannel, Collection, Message } from "discord.js"; +import { cfg } from "../../systems/config"; +import { polls } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; + +export async function handlePurge(interaction: ChatInputCommandInteraction): Promise { + const channelId = cfg("pollChannelId"); + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found."); + + // Clear active poll state + polls.clear(); + + // Fetch and delete bot messages in bulk (Discord allows bulk delete for messages < 14 days old) + let deleted = 0; + let messages: Collection; + + do { + messages = await channel.messages.fetch({ limit: 100 }); + const botMessages = messages.filter((m) => m.author.id === interaction.client.user!.id); + if (botMessages.size === 0) break; + + if (botMessages.size === 1) { + await botMessages.first()!.delete(); + } else { + await channel.bulkDelete(botMessages, true); // true = skip messages older than 14 days + } + deleted += botMessages.size; + } while (messages.size === 100); + + return void replyAndDelete(interaction, `🗑️ Deleted **${deleted}** bot message(s) from <#${channelId}>.`); +} \ No newline at end of file diff --git a/src/subcommands/poll/reload.ts b/src/subcommands/poll/reload.ts new file mode 100644 index 0000000..b332963 --- /dev/null +++ b/src/subcommands/poll/reload.ts @@ -0,0 +1,12 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { loadMessages } from "../../systems/messages"; +import { loadEmojis } from "../../systems/emojis"; +import { loadCharacters } from "../../systems/characters"; +import { replyAndDelete } from "../../utils"; + +export async function handleReload(interaction: ChatInputCommandInteraction): Promise { + loadMessages(); // reloads global.json, usermap.json, users/*.json + loadEmojis(); // reloads emojis.json + loadCharacters(); // reloads characters.json and accounts.json + return void replyAndDelete(interaction, "🔄 Messages, emojis and characters reloaded from disk."); +} \ No newline at end of file diff --git a/src/subcommands/poll/seed.ts b/src/subcommands/poll/seed.ts new file mode 100644 index 0000000..9bdc5ae --- /dev/null +++ b/src/subcommands/poll/seed.ts @@ -0,0 +1,61 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { nowFormatted, resolveMessage } from "../../systems/messages"; +import { getEffectiveCharacter } from "../../systems/borrow"; +import { replyAndDelete } from "../../utils"; +import { VoteEntry, UsermapEntry } from "../../types"; +import fs from "fs"; +import path from "path"; + +export async function handleSeed(interaction: ChatInputCommandInteraction): Promise { + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); + + const state = polls.get(slot)!; + if (state.locked || state.confirmed !== null) { + return void replyAndDelete(interaction, "❌ Poll is locked or confirmed."); + } + + let usermap: Record = {}; + try { + usermap = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../data/usermap.json"), "utf8")); + } catch { + return void replyAndDelete(interaction, "❌ Could not load usermap.json."); + } + + const now = nowFormatted(); + let injected = 0; + let skipped = 0; + + for (const [discordUsername, entry] of Object.entries(usermap)) { + const usermapKey = typeof entry === "string" ? entry : entry.file; + const { char, borrowedFrom } = getEffectiveCharacter(usermapKey); + + if (!char) { skipped++; continue; } + + const syntheticId = `injected:${usermapKey}`; + if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; } + + const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null); + + const voteEntry: VoteEntry = { + usermapKey, + displayName: char.name, + characterName: char.name, + characterClass: char.class, + characterLevel: char.level, + characterNation: char.nation, + borrowedFrom: borrowedFrom ?? undefined, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }; + + state.yes.set(syntheticId, voteEntry); + injected++; + } + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot); + return void replyAndDelete(interaction, `✅ Seeded **${injected}** player(s)${skipped > 0 ? `, skipped **${skipped}** (no active character)` : ""}.`); +} \ No newline at end of file diff --git a/src/subcommands/poll/setMessage.ts b/src/subcommands/poll/setMessage.ts new file mode 100644 index 0000000..ab5bf95 --- /dev/null +++ b/src/subcommands/poll/setMessage.ts @@ -0,0 +1,84 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { setPublicOverride, clearPublicOverride, setEphemeralOverride, clearEphemeralOverride } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; +import { hasOfficerRole } from "../../systems/users"; + +export async function handleSetMessage(interaction: ChatInputCommandInteraction): Promise { + const nameArg = interaction.options.getString("name"); + const voteType = interaction.options.getString("vote_type", true) as "yes" | "no"; + const message = interaction.options.getString("message", true); + + // Resolve target user + let targetUserId: string; + if (nameArg) { + const member = interaction.guild!.members.cache.find( + (m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg + ); + if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`); + targetUserId = member.user.id; + } else { + targetUserId = interaction.user.id; + } + + setPublicOverride(targetUserId, voteType, message); + return void replyAndDelete(interaction, `✅ Public message set for **${voteType}** vote.`); +} + +export async function handleClearMessage(interaction: ChatInputCommandInteraction): Promise { + const nameArg = interaction.options.getString("name"); + const voteType = interaction.options.getString("vote_type") as "yes" | "no" | null; + + let targetUserId: string; + if (nameArg) { + const member = interaction.guild!.members.cache.find( + (m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg + ); + if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`); + targetUserId = member.user.id; + } else { + targetUserId = interaction.user.id; + } + + clearPublicOverride(targetUserId, voteType ?? undefined); + return void replyAndDelete(interaction, `✅ Public message override cleared.`); +} + +export async function handleSetEphemeral(interaction: ChatInputCommandInteraction): Promise { + const nameArg = interaction.options.getString("name"); + const voteType = interaction.options.getString("vote_type", true) as "yes" | "no"; + const message = interaction.options.getString("message", true); + + let targetUserId: string; + if (nameArg) { + const member = interaction.guild!.members.cache.find( + (m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg + ); + if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`); + targetUserId = member.user.id; + } else { + targetUserId = interaction.user.id; + } + + setEphemeralOverride(targetUserId, voteType, message); + return void replyAndDelete(interaction, `✅ Ephemeral message set for **${voteType}** vote.`); +} + +export async function handleClearEphemeral(interaction: ChatInputCommandInteraction): Promise { + const nameArg = interaction.options.getString("name"); + const voteType = interaction.options.getString("vote_type") as "yes" | "no" | null; + + let targetUserId: string; + if (nameArg) { + const member = interaction.guild!.members.cache.find( + (m) => (m.nickname ?? m.user.globalName ?? m.user.username) === nameArg + ); + if (!member) return void replyAndDelete(interaction, `❌ Could not find member "${nameArg}".`); + targetUserId = member.user.id; + } else { + targetUserId = interaction.user.id; + } + + clearEphemeralOverride(targetUserId, voteType ?? undefined); + return void replyAndDelete(interaction, `✅ Ephemeral message override cleared.`); +} diff --git a/src/subcommands/poll/start.ts b/src/subcommands/poll/start.ts new file mode 100644 index 0000000..a0078eb --- /dev/null +++ b/src/subcommands/poll/start.ts @@ -0,0 +1,31 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { postPoll } from "../../systems/poll"; +import { resetClickCounts } from "../../handlers/buttons"; +import { replyAndDelete } from "../../utils"; +import { TGSlot } from "../../types"; + +export async function handleStart(interaction: ChatInputCommandInteraction): Promise { + const slotArg = interaction.options.getString("slot"); + const slots = cfg("slots").filter((s) => s.active); + + let slot: TGSlot | undefined; + if (slotArg) { + const hour = parseInt(slotArg); + slot = slots.find((s) => s.tgHour === hour); + if (!slot) return void replyAndDelete(interaction, `❌ No active slot for hour ${slotArg}.`); + } else { + slot = slots[0]; + } + if (!slot) return void replyAndDelete(interaction, "❌ No active TG slots configured."); + + const channelId = cfg("pollChannelId"); + console.log("pollChannelId:", channelId); + console.log("POLL_CHANNEL_ID env:", process.env.POLL_CHANNEL_ID); + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + if (!channel) return void replyAndDelete(interaction, "❌ Poll channel not found."); + + resetClickCounts(); + await replyAndDelete(interaction, `⚔️ Posting TG poll for ${slot.tgHour}:00...`); + await postPoll(channel, slot); +} \ No newline at end of file diff --git a/src/subcommands/poll/status.ts b/src/subcommands/poll/status.ts new file mode 100644 index 0000000..96c03cb --- /dev/null +++ b/src/subcommands/poll/status.ts @@ -0,0 +1,30 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { polls } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; + +export async function handleStatus(interaction: ChatInputCommandInteraction): Promise { + const activePolls = [...polls.entries()].map(([slot, s]) => + `Slot ${slot}:00 — locked: ${s.locked}, confirmed: ${s.confirmed ?? "no"}, yes: ${s.yes.size}, no: ${s.no.size}` + ).join("\n") || "None"; + + const status = [ + `**Officer roles:** ${cfg("officerRoles").join(", ")}`, + `**Config roles:** ${cfg("configRoles").join(", ")}`, + `**Tag roles:** ${cfg("tagRoles").join(", ")}`, + `**Lock message:** ${cfg("lockMessage")}`, + `**Confirm yes:** ${cfg("confirmYesMessage")}`, + `**Confirm no:** ${cfg("confirmNoMessage")}`, + `**Poll channel:** ${cfg("pollChannelId") || "not set"}`, + `**Results channel:** ${cfg("resultsChannelId") || "not set"}`, + `**Score channel:** ${cfg("scoreChannelId") || "not set"}`, + `**Score window:** ${cfg("scoreWindowHours")}h`, + `**TG duration:** ${cfg("tgDurationMinutes")}min`, + `**Nation source:** ${cfg("nationSource")}`, + `**W.Rank goal:** ${cfg("wRankGoal")} TGs`, + `**Timezone:** ${process.env.TZ ?? "Etc/GMT-2"}`, + `**Active polls:**\n${activePolls}`, + ].join("\n"); + + return void replyAndDelete(interaction, status); +} diff --git a/src/subcommands/poll/unlock.ts b/src/subcommands/poll/unlock.ts new file mode 100644 index 0000000..5853f42 --- /dev/null +++ b/src/subcommands/poll/unlock.ts @@ -0,0 +1,20 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { loadMessages } from "../../systems/messages"; +import { replyAndDelete } from "../../utils"; + +export async function handleUnlock(interaction: ChatInputCommandInteraction): Promise { + const slot = [...polls.keys()][0]; + if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); + + const state = polls.get(slot)!; + if (!state.locked) return void replyAndDelete(interaction, "ℹ️ The poll isn't locked."); + + state.locked = false; + loadMessages(); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot); + return void replyAndDelete(interaction, "🔓 Poll unlocked!"); +} diff --git a/src/subcommands/rank/get.ts b/src/subcommands/rank/get.ts new file mode 100644 index 0000000..604132d --- /dev/null +++ b/src/subcommands/rank/get.ts @@ -0,0 +1,52 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; +import { replyAndDelete } from "../../utils"; + +export async function handleRankGet(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"); + + if (nameArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks."); + } + + let usermapKey: string | null; + if (nameArg) { + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) 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); + 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 lines = [ + `**${entry.characterName}** · ${entry.nation}`, + `**W.Rank:** ${entry.currentRank}${deltaStr}${isBringer ? ` · ${entry.nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""}`, + `**Points:** ${entry.weeklyPoints}`, + `**TGs done:** ${entry.tgCount}/${goal}${isDone ? " ✅" : ""}`, + `**Week:** ${weekKey}`, + ].join("\n"); + + return void replyAndDelete(interaction, lines); + } + + return void replyAndDelete(interaction, `❌ No rank found for **${usermapKey}** this week.`); +} diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts new file mode 100644 index 0000000..8418540 --- /dev/null +++ b/src/subcommands/rank/post.ts @@ -0,0 +1,39 @@ +import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; +import { cfg } from "../../systems/config"; +import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank"; +import { replyAndDelete } from "../../utils"; + +export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise { + const week = getCurrentWeek(); + const goal = cfg("wRankGoal"); + const weekKey = getWeekKey(); + + const formatNation = (nation: "capella" | "procyon"): string => { + const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank); + if (entries.length === 0) return "—"; + const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon"); + return entries.map((e) => { + const isDone = e.tgCount >= goal; + const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0; + const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : ""; + const bringerStr = bringer === e.usermapKey && isDone + ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` + : ""; + return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; + }).join("\n"); + }; + + const embed = new EmbedBuilder() + .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`) + .setColor(0xe8a317) + .addFields( + { name: "🔵 Capella", value: formatNation("capella"), inline: true }, + { name: "🔴 Procyon", value: formatNation("procyon"), inline: true }, + ) + .setTimestamp(); + + const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + await channel.send({ embeds: [embed] }); + return void replyAndDelete(interaction, "✅ Leaderboard posted."); +} diff --git a/src/subcommands/result/post.ts b/src/subcommands/result/post.ts new file mode 100644 index 0000000..32425a0 --- /dev/null +++ b/src/subcommands/result/post.ts @@ -0,0 +1,46 @@ +import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js"; +import { cfg } from "../../systems/config"; +import { loadResult, todayString } from "../../systems/history"; +import { normalizeSlot, detectSlot } from "../../systems/scores"; +import { replyAndDelete } from "../../utils"; + +export async function handleResultPost(interaction: ChatInputCommandInteraction): Promise { + const slotArg = interaction.options.getString("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() ?? cfg("slots")[0]?.tgHour ?? 20; + } + + const result = loadResult(todayString(), slot); + if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG.`); + + const kd = result.nationKD; + + const formatScores = (nation: "Capella" | "Procyon"): string => { + const scores = result.scores.filter((s) => s.nation === nation); + if (scores.length === 0) return "—"; + return scores + .sort((a, b) => b.pts - a.pts) + .map((s) => `**${s.characterName}** (${s.class}) — ${s.pts} pts`) + .join("\n"); + }; + + const embed = new EmbedBuilder() + .setTitle(`⚔️ TG Results — ${result.date} ${slot}:00`) + .setColor(0xe8a317) + .addFields( + { name: "🔵 Capella", value: `${kd.capella.k}K / ${kd.capella.d}D\n${formatScores("Capella")}`, inline: true }, + { name: "🔴 Procyon", value: `${kd.procyon.k}K / ${kd.procyon.d}D\n${formatScores("Procyon")}`, inline: true }, + ) + .setFooter({ text: `Source of truth: ${kd.source}` }) + .setTimestamp(); + + const channelId = cfg("resultsChannelId") || cfg("pollChannelId"); + const channel = await interaction.client.channels.fetch(channelId) as TextChannel; + await channel.send({ embeds: [embed] }); + return void replyAndDelete(interaction, "✅ Results posted."); +} diff --git a/src/subcommands/result/set.ts b/src/subcommands/result/set.ts new file mode 100644 index 0000000..c37ed87 --- /dev/null +++ b/src/subcommands/result/set.ts @@ -0,0 +1,30 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { setNationKD } from "../../systems/history"; +import { normalizeSlot, detectSlot } from "../../systems/scores"; +import { todayString } from "../../systems/history"; +import { replyAndDelete } from "../../utils"; +import { Nation } from "../../types"; + +export async function handleResultSet(interaction: ChatInputCommandInteraction): Promise { + const slotArg = interaction.options.getString("slot"); + const nation = interaction.options.getString("nation", true) as Nation; + const kills = interaction.options.getInteger("kills", true); + const deaths = interaction.options.getInteger("deaths", true); + + 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() ?? cfg("slots")[0]?.tgHour ?? 20; + } + + const result = setNationKD(todayString(), slot, nation, kills, deaths); + const other = nation === "Capella" ? "Procyon" : "Capella"; + const otherKD = result.nationKD[other.toLowerCase() as "capella" | "procyon"]; + + return void replyAndDelete(interaction, + `✅ **${nation}** K/D set: ${kills}/${deaths}\n**${other}** K/D (auto): ${otherKD.k}/${otherKD.d}` + ); +} diff --git a/src/subcommands/result/view.ts b/src/subcommands/result/view.ts new file mode 100644 index 0000000..e7c70dc --- /dev/null +++ b/src/subcommands/result/view.ts @@ -0,0 +1,31 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { loadResult, todayString } from "../../systems/history"; +import { normalizeSlot, detectSlot } from "../../systems/scores"; +import { replyAndDelete } from "../../utils"; + +export async function handleResultView(interaction: ChatInputCommandInteraction): Promise { + const slotArg = interaction.options.getString("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() ?? cfg("slots")[0]?.tgHour ?? 20; + } + + const result = loadResult(todayString(), slot); + if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`); + + const kd = result.nationKD; + const lines = [ + `**TG ${slot}:00 — ${result.date}**`, + `**Capella:** ${kd.capella.k}K / ${kd.capella.d}D`, + `**Procyon:** ${kd.procyon.k}K / ${kd.procyon.d}D`, + `**Scores submitted:** ${result.scores.length}`, + result.scores.map((s) => `• ${s.characterName} (${s.class}) — ${s.pts} pts`).join("\n"), + ].join("\n"); + + return void replyAndDelete(interaction, lines); +} diff --git a/src/subcommands/score/get.ts b/src/subcommands/score/get.ts new file mode 100644 index 0000000..1b82559 --- /dev/null +++ b/src/subcommands/score/get.ts @@ -0,0 +1,52 @@ +import { ChatInputCommandInteraction } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { normalizeSlot, detectSlot } from "../../systems/scores"; +import { loadResult, todayString } from "../../systems/history"; +import { replyAndDelete } from "../../utils"; + +export async function handleScoreGet(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 slotArg = interaction.options.getString("slot"); + + if (nameArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can view other players' scores."); + } + + let usermapKey: string | null; + if (nameArg) { + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + 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() ?? cfg("slots")[0]?.tgHour ?? 20; + } + + const result = loadResult(todayString(), slot); + if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`); + + 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.`); + + 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" })}`, + ].filter(Boolean).join("\n"); + + return void replyAndDelete(interaction, lines); +} diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts new file mode 100644 index 0000000..b813206 --- /dev/null +++ b/src/subcommands/score/set.ts @@ -0,0 +1,57 @@ +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"; + +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; + + if (nameArg) { + if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const char = getActiveCharacter(usermapKey); + 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."); + } + } + + await submitScore({ + usermapKey, + characterName: char.name, + cls: char.class, + nation: char.nation, + pts: ptsArg, + slot, + submittedByOfficer: isOfficer && !!nameArg, + }); + + return void replyAndDelete(interaction, `✅ Score of **${ptsArg}** submitted for **${char.name}** (${slot}:00 TG).`); +} diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts new file mode 100644 index 0000000..f7192e4 --- /dev/null +++ b/src/subcommands/switch.ts @@ -0,0 +1,102 @@ +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 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 { + 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; + const char = data.characters?.find( + (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey) + ); + if (char) return { ownerKey, char }; + } + } catch {} + return null; +} + +// Reverse-lookup: find Discord userId for a usermapKey from current poll voters +function findUserIdInPoll(state: any, usermapKey: string): string | null { + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + if (entry.usermapKey === usermapKey) return id; + } + return null; +} + +export async function handleSwitch(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 charName = interaction.options.getString("char_name", true); + + let usermapKey: string | null; + if (nameArg) { + if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters."); + usermapKey = nameArg; + } else { + const user = await resolveUser(member); + usermapKey = user.usermapKey; + } + + if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + let resolvedChar: any = null; + let borrowedFrom: string | null = null; + + // Try own characters first + const set = setActiveCharacter(usermapKey, charName); + if (set) { + resolvedChar = getActiveCharacter(usermapKey); + } else { + // Fall back to shared characters + const shared = findSharedChar(usermapKey, 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)); + } + } + + if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); + + // Update poll embed if user has voted + const slot = [...polls.keys()][0]; + if (slot !== undefined) { + const state = polls.get(slot)!; + const userId = nameArg + ? findUserIdInPoll(state, usermapKey) + : interaction.user.id; + + if (userId && (state.yes.has(userId) || state.no.has(userId))) { + const updateEntry = (map: Map) => { + const entry = map.get(userId); + if (entry) { + entry.characterName = resolvedChar.name; + entry.characterClass = resolvedChar.class; + entry.characterLevel = resolvedChar.level; + entry.characterNation = resolvedChar.nation; + entry.borrowedFrom = borrowedFrom ?? undefined; + } + }; + updateEntry(state.yes); + updateEntry(state.no); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot); + } + } + + const borrowNote = borrowedFrom ? ` (shared by **${borrowedFrom}**)` : ""; + return void replyAndDelete(interaction, `✅ Switched to **${charName}**${borrowNote}.`); +} \ No newline at end of file diff --git a/src/systems/borrow.ts b/src/systems/borrow.ts new file mode 100644 index 0000000..30c790f --- /dev/null +++ b/src/systems/borrow.ts @@ -0,0 +1,158 @@ +import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; +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}` + +// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start +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 { + return `${ownerKey}:${requesterKey}`; +} + +export function getPendingRequest(ownerKey: string, requesterKey: string): BorrowRequest | null { + return pendingRequests.get(requestKey(ownerKey, requesterKey)) ?? null; +} + +export function getPendingRequestByKey(key: string): BorrowRequest | null { + return pendingRequests.get(key) ?? null; +} + +export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] { + return [...pendingRequests.values()].filter((r) => r.ownerKey === ownerKey); +} + +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) { + pendingRequests.delete(key); + console.log(`[borrow] Request ${key} expired.`); + } + }, expiry); + } +} + +export function removePendingRequest(ownerKey: string, requesterKey: string): void { + pendingRequests.delete(requestKey(ownerKey, requesterKey)); +} + +export function storeDmMessage(ownerKey: string, requesterKey: string, channelId: string, messageId: string): void { + borrowDmMessages.set(requestKey(ownerKey, requesterKey), { channelId, messageId }); +} + +export function getDmMessage(ownerKey: string, requesterKey: string): { channelId: string; messageId: string } | null { + return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null; +} + +// Session borrow management +export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void { + sessionBorrows.set(requesterKey, { ownerKey, charName }); +} + +export function getSessionBorrow(requesterKey: string): { ownerKey: string; charName: string } | null { + return sessionBorrows.get(requesterKey) ?? null; +} + +export function clearSessionBorrows(): void { + sessionBorrows.clear(); + 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 async function sendBorrowRequestDM( + client: Client, + ownerDiscordId: string, + requesterDisplayName: string, + ownerKey: string, + requesterKey: string, + charName: string, + charClass: string, + charLevel: number, + fallbackChannel?: TextChannel +): Promise { + const content = `🔔 **Borrow Request**\n**${requesterDisplayName}** wants to borrow **«${charName}»** (${charClass} · Lv${charLevel}) for tonight's TG.`; + + const acceptBtn = new ButtonBuilder() + .setCustomId(`borrow_accept:${ownerKey}:${requesterKey}`) + .setLabel("✅ Accept") + .setStyle(ButtonStyle.Success); + + const declineBtn = new ButtonBuilder() + .setCustomId(`borrow_decline:${ownerKey}:${requesterKey}`) + .setLabel("❌ Decline") + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents(acceptBtn, declineBtn); + + try { + const ownerUser = await client.users.fetch(ownerDiscordId); + const dm = await ownerUser.createDM(); + 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}\`.`, + }); + } + } +} + +// Update DM after accept/decline to disable buttons +export async function updateBorrowDM( + client: Client, + ownerKey: string, + requesterKey: string, + accepted: boolean +): Promise { + const dm = getDmMessage(ownerKey, requesterKey); + if (!dm) return; + try { + const channel = await client.channels.fetch(dm.channelId) as any; + 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 + } +} + +// 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); + if (borrow) { + const char = getCharacterByName(borrow.ownerKey, borrow.charName); + if (char) return { char, borrowedFrom: borrow.ownerKey }; + } + const char = getActiveCharacter(usermapKey); + return { char: char ?? null, borrowedFrom: null }; +} \ No newline at end of file diff --git a/src/systems/characters.ts b/src/systems/characters.ts new file mode 100644 index 0000000..207f430 --- /dev/null +++ b/src/systems/characters.ts @@ -0,0 +1,111 @@ +import fs from "fs"; +import path from "path"; +import { CharacterMap, Character, ClassKey, Nation, AccountMap, AccountData } from "../types"; + +const CHARS_PATH = path.join(__dirname, "../../data/characters.json"); +const ACCOUNTS_PATH = path.join(__dirname, "../../data/accounts.json"); + +let _chars: CharacterMap = {}; +let _accounts: AccountMap = {}; + +export function loadCharacters(): void { + try { _chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); } + catch { _chars = {}; } + try { _accounts = JSON.parse(fs.readFileSync(ACCOUNTS_PATH, "utf8")); } + catch { _accounts = {}; } +} + +function saveCharacters(): void { + fs.writeFileSync(CHARS_PATH, JSON.stringify(_chars, null, 2)); +} + +function saveAccounts(): void { + fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2)); +} + +export function getCharacters(usermapKey: string): Character[] { + return _chars[usermapKey]?.characters ?? []; +} + +export function getActiveCharacter(usermapKey: string): Character | null { + return getCharacters(usermapKey).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 getCharacterByClass(usermapKey: string, cls: ClassKey): Character | null { + // Returns the active character of that class, or first found + const chars = getCharacters(usermapKey).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()); + 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 }); + 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( + (c) => c.name.toLowerCase() !== name.toLowerCase() + ); + if (_chars[usermapKey].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; + } + saveCharacters(); + return true; +} + +export function setActiveCharacter(usermapKey: string, name: string): boolean { + const chars = _chars[usermapKey]?.characters; + if (!chars) return false; + const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase()); + if (!target) return false; + chars.forEach((c) => (c.active = false)); + target.active = true; + saveCharacters(); + return true; +} + +export function setCharacterNation(usermapKey: string, name: string, nation: Nation): boolean { + const char = getCharacterByName(usermapKey, name); + if (!char) return false; + char.nation = nation; + saveCharacters(); + return true; +} + +export function setCharacterStats( + usermapKey: string, + name: string, + stats: { atk?: number; def?: number; heal?: number } +): boolean { + const char = getCharacterByName(usermapKey, name); + if (!char) return false; + if (!char.stats) char.stats = {}; + Object.assign(char.stats, stats); + saveCharacters(); + return true; +} + +// ─── Account data ───────────────────────────────────────────────────────────── +export function getAccountData(usermapKey: string): AccountData { + return _accounts[usermapKey] ?? {}; +} + +export function setAccountData(usermapKey: string, data: Partial): void { + if (!_accounts[usermapKey]) _accounts[usermapKey] = {}; + Object.assign(_accounts[usermapKey], data); + saveAccounts(); +} diff --git a/src/systems/config.ts b/src/systems/config.ts new file mode 100644 index 0000000..cb7bfb0 --- /dev/null +++ b/src/systems/config.ts @@ -0,0 +1,66 @@ +import fs from "fs"; +import path from "path"; +import { BotConfig, Nation } from "../types"; + +const CONFIG_PATH = path.join(__dirname, "../../data/config.json"); + +// Function instead of const so env vars are read lazily at call time +function getDefaults(): Required { + return { + officerRoles: ["Ice King"], + configRoles: ["Ice King"], + tagRoles: ["Ice King", "Ice", "Rebellion"], + lockMessage: "🔒 This poll has been locked.", + confirmYesMessage: "⚔️ TG is confirmed for tonight!", + confirmNoMessage: "❌ TG is cancelled for tonight.", + pollChannelId: process.env.POLL_CHANNEL_ID ?? "", + resultsChannelId: process.env.RESULTS_CHANNEL_ID ?? "", + scoreChannelId: process.env.SCORE_CHANNEL_ID ?? "", + slots: [ + { tgHour: 20, pollOpens: "10:00", closesAfter: 35, active: true }, + ], + scoreWindowHours: 2, + tgDurationMinutes: 35, + nationSource: "Procyon" as Nation, + wRankPostOnReset: false, + wRankGoal: 7, + wRankYellowColor: "#BA7517", + wRankGrayColor: "#888888", + deltaUpColor: "#A32D2D", + deltaDownColor: "#185FA5", + stormBringerColor: "#185FA5", + luminousBringerColor: "#8B4CB8", + showClassInMessages: false, + showLevelInMessages: false, + charDisplayFormat: "{wrank} {class} {level} {name}", + showNationTotalsInHeader: false, + showNoInNationField: false, + borrowRequestExpiryMs: 0, // 0 = never expire + }; +} + +let _cfg: BotConfig = {}; + +export function loadConfig(): void { + try { _cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } + catch { _cfg = {}; } +} + +export function saveConfig(): void { + try { fs.writeFileSync(CONFIG_PATH, JSON.stringify(_cfg, null, 2)); } + catch (err) { console.error("Failed to save config.json:", err); } +} + +export function cfg(key: K): Required[K] { + return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required[K]; +} + +export function setCfg(key: K, value: BotConfig[K]): void { + _cfg[key] = value; + saveConfig(); +} + +export function resetCfg(key: K): void { + delete _cfg[key]; + saveConfig(); +} \ No newline at end of file diff --git a/src/systems/emojis.ts b/src/systems/emojis.ts new file mode 100644 index 0000000..434fa9e --- /dev/null +++ b/src/systems/emojis.ts @@ -0,0 +1,27 @@ +import fs from "fs"; +import path from "path"; +import { EmojiMap, ClassKey } from "../types"; + +const EMOJI_PATH = path.join(__dirname, "../../messages/emojis.json"); +let _emojis: EmojiMap = {}; + +export function loadEmojis(): void { + try { _emojis = JSON.parse(fs.readFileSync(EMOJI_PATH, "utf8")); } + catch (err) { console.error("Failed to load emojis.json:", err); _emojis = {}; } +} + +export function getEmoji(key: string): string { + return _emojis[key] ?? ""; +} + +export function getClassEmoji(cls: ClassKey): string { + return getEmoji(cls.toLowerCase()); +} + +export function getNationEmoji(nation: string): string { + return getEmoji(nation.toLowerCase()); +} + +export function resolveEmojiTokens(text: string): string { + return text.replace(/\{emoji:([^}]+)\}/g, (_, key: string) => getEmoji(key)); +} diff --git a/src/systems/history.ts b/src/systems/history.ts new file mode 100644 index 0000000..af92803 --- /dev/null +++ b/src/systems/history.ts @@ -0,0 +1,92 @@ +import fs from "fs"; +import path from "path"; +import { TGResult, TGScore, Nation } from "../types"; +import { oppositeNation } from "./nations"; + +const HISTORY_DIR = path.join(__dirname, "../../data/tg-history"); + +function historyKey(date: string, slot: number): string { + return `${date}-${String(slot).padStart(2, "0")}`; +} + +function historyPath(key: string): string { + return path.join(HISTORY_DIR, `${key}.json`); +} + +export function loadResult(date: string, slot: number): TGResult | null { + try { + return JSON.parse(fs.readFileSync(historyPath(historyKey(date, slot)), "utf8")); + } catch { + return null; + } +} + +export function saveResult(result: TGResult): void { + if (!fs.existsSync(HISTORY_DIR)) fs.mkdirSync(HISTORY_DIR, { recursive: true }); + const key = historyKey(result.date, result.slot); + fs.writeFileSync(historyPath(key), JSON.stringify(result, null, 2)); +} + +export function upsertScore(score: TGScore): void { + const result = loadResult(score.date, score.slot) ?? { + slot: score.slot, + date: score.date, + confirmed: false, + nationKD: { + source: "Procyon" as Nation, + capella: { k: 0, d: 0 }, + procyon: { k: 0, d: 0 }, + }, + scores: [], + }; + + // 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) + ); + result.scores.push(score); + saveResult(result); +} + +export function setNationKD( + date: string, + slot: number, + sourceNation: Nation, + k: number, + d: number +): TGResult { + const result = loadResult(date, slot) ?? { + slot, + date, + confirmed: false, + nationKD: { source: sourceNation, capella: { k: 0, d: 0 }, procyon: { k: 0, d: 0 } }, + scores: [], + }; + + result.nationKD.source = sourceNation; + const other = oppositeNation(sourceNation); + + result.nationKD[sourceNation.toLowerCase() as "capella" | "procyon"] = { k, d }; + result.nationKD[other.toLowerCase() as "capella" | "procyon"] = { k: d, d: k }; + + saveResult(result); + return result; +} + +export function listRecentResults(limit = 10): TGResult[] { + if (!fs.existsSync(HISTORY_DIR)) return []; + return fs.readdirSync(HISTORY_DIR) + .filter((f) => f.endsWith(".json")) + .sort() + .reverse() + .slice(0, limit) + .map((f) => { + try { return JSON.parse(fs.readFileSync(path.join(HISTORY_DIR, f), "utf8")) as TGResult; } + catch { return null; } + }) + .filter(Boolean) as TGResult[]; +} + +export function todayString(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/src/systems/messages.ts b/src/systems/messages.ts new file mode 100644 index 0000000..e59c1a1 --- /dev/null +++ b/src/systems/messages.ts @@ -0,0 +1,164 @@ +import fs from "fs"; +import path from "path"; +import { MessagesFile, MessageEntry, Usermap, UsermapEntry } from "../types"; +import { resolveEmojiTokens } from "./emojis"; + +const MESSAGES_DIR = path.join(__dirname, "../../messages"); +const USERMAP_PATH = path.join(__dirname, "../../data/usermap.json"); + +interface LoadedMessages { + global: MessagesFile; + users: Record; + userMap: Usermap; +} + +let MESSAGES: LoadedMessages = { + global: { public: { yes: [], no: [] }, ephemeral: { yes: [], no: [] } }, + users: {}, + userMap: {}, +}; + +export function loadMessages(): void { + try { + MESSAGES.global = JSON.parse(fs.readFileSync(path.join(MESSAGES_DIR, "global.json"), "utf8")); + } catch (err) { + console.error("Failed to load global.json:", err); + } + + try { + MESSAGES.userMap = JSON.parse(fs.readFileSync(USERMAP_PATH, "utf8")); + } catch { + MESSAGES.userMap = {}; + } + + const usersDir = path.join(MESSAGES_DIR, "users"); + MESSAGES.users = {}; + if (fs.existsSync(usersDir)) { + for (const file of fs.readdirSync(usersDir)) { + if (!file.endsWith(".json")) continue; + const key = path.basename(file, ".json"); + try { + MESSAGES.users[key] = JSON.parse(fs.readFileSync(path.join(usersDir, file), "utf8")); + } catch (err) { + console.error(`Failed to load user message file ${file}:`, err); + } + } + } + + console.log(`Messages loaded — ${Object.keys(MESSAGES.users).length} user file(s).`); +} + +// ─── Date/time helpers ─────────────────────────────────────────────────────── +const DAY_NAMES = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"]; +const MONTH_NAMES = ["january","february","march","april","may","june","july","august","september","october","november","december"]; +const TIMEZONE = process.env.TZ ?? "Etc/GMT-2"; + +export function getNow() { + const now = new Date(); + const weekdayStr = now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, weekday: "long" }).toLowerCase(); + const dayNum = parseInt(now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, day: "numeric" })); + const monthNum = parseInt(now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, month: "numeric" })); + const yearNum = parseInt(now.toLocaleDateString("en-GB", { timeZone: TIMEZONE, year: "numeric" })); + return { + dayName: weekdayStr, + dayNum, + monthNum, + yearNum, + dateKey: `${String(dayNum).padStart(2,"0")}-${String(monthNum).padStart(2,"0")}`, + monthName: MONTH_NAMES[monthNum - 1], + }; +} + +export function nowFormatted(): string { + return new Date().toLocaleTimeString("en-GB", { timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit" }); +} + +// ─── Interpolation ─────────────────────────────────────────────────────────── +export function interpolate( + message: string, + username: string, + serverNickname: string | null, + globalNickname: string | null, + aliases: string[] +): string { + const displayName = serverNickname ?? globalNickname ?? username; + const dt = getNow(); + + let result = message + .replace(/\{username\}/g, username) + .replace(/\{server_nickname\}/g, serverNickname ?? displayName) + .replace(/\{nickname\}/g, globalNickname ?? username) + .replace(/\{alias\[random\]\}/g, () => + aliases.length > 0 ? aliases[Math.floor(Math.random() * aliases.length)] : displayName + ) + .replace(/\{alias\[(\d+)\]\}/g, (_, i: string) => aliases[parseInt(i)] ?? displayName) + .replace(/\{alias\}/g, aliases[0] ?? displayName) + .replace(/\{DAY\}/g, dt.dayName.toUpperCase()) + .replace(/\{Day\}/g, dt.dayName.charAt(0).toUpperCase() + dt.dayName.slice(1)) + .replace(/\{day\}/g, dt.dayName) + .replace(/\{MONTH\}/g, dt.monthName.toUpperCase()) + .replace(/\{Month\}/g, dt.monthName.charAt(0).toUpperCase() + dt.monthName.slice(1)) + .replace(/\{month\}/g, dt.monthName) + .replace(/\{month_num\}/g, String(dt.monthNum).padStart(2, "0")) + .replace(/\{date_full\}/g, `${String(dt.dayNum).padStart(2,"0")}-${String(dt.monthNum).padStart(2,"0")}-${dt.yearNum}`) + .replace(/\{date\}/g, dt.dateKey) + .replace(/\{day_num\}/g, String(dt.dayNum).padStart(2, "0")); + + return resolveEmojiTokens(result); +} + +// ─── Resolution ────────────────────────────────────────────────────────────── +function pickFromEntry(entry: MessageEntry, dayName: string, dateKey: string): string | null { + const datePool = entry.dates?.[dateKey]; + const dayPool = entry.days?.[dayName]; + + for (const pool of [datePool, dayPool, entry]) { + if (!pool) continue; + const msgs = (pool as { messages?: string[]; message?: string; random?: boolean }).messages; + const msg = (pool as { message?: string }).message; + if (Array.isArray(msgs) && msgs.length > 0) { + return (pool as { random?: boolean }).random + ? msgs[Math.floor(Math.random() * msgs.length)] + : msgs[0]; + } + if (msg) return msg; + } + return null; +} + +function bestMatch(pool: MessageEntry[], clicks: number, dayName: string, dateKey: string): string | null { + if (!pool || pool.length === 0) return null; + const sorted = [...pool].filter((e) => e.clicks <= clicks).sort((a, b) => b.clicks - a.clicks); + if (!sorted[0]) return null; + return pickFromEntry(sorted[0], dayName, dateKey); +} + +export function resolveMessage( + block: "public" | "ephemeral", + voteType: "yes" | "no", + clicks: number, + discordUsername: string, + serverNickname: string | null, + globalNickname: string | null +): string | null { + const entry = MESSAGES.userMap[discordUsername]; + const fileKey = typeof entry === "string" ? entry : (entry as UsermapEntry)?.file ?? discordUsername; + const aliases = typeof entry === "object" && entry !== null ? (entry as UsermapEntry).aliases ?? [] : []; + + const userPool = MESSAGES.users[fileKey]?.[block]?.[voteType] ?? []; + const globalPool = MESSAGES.global?.[block]?.[voteType] ?? []; + + const dt = getNow(); + const raw = bestMatch(userPool, clicks, dt.dayName, dt.dateKey) + ?? bestMatch(globalPool, clicks, dt.dayName, dt.dateKey) + ?? null; + + return raw ? interpolate(raw, discordUsername, serverNickname, globalNickname, aliases) : null; +} + +export function getUsermapEntry(discordUsername: string): UsermapEntry | null { + const entry = MESSAGES.userMap[discordUsername]; + if (!entry) return null; + if (typeof entry === "string") return { file: entry, aliases: [] }; + return entry as UsermapEntry; +} diff --git a/src/systems/nations.ts b/src/systems/nations.ts new file mode 100644 index 0000000..06d471a --- /dev/null +++ b/src/systems/nations.ts @@ -0,0 +1,22 @@ +import { GuildMember } from "discord.js"; +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 { + // 1. Active character's nation + if (usermapKey) { + const char = getActiveCharacter(usermapKey); + if (char) return char.nation; + } + + // 2. Discord role fallback + if (member.roles.cache.some((r) => r.name === "Capella")) return "Capella"; + if (member.roles.cache.some((r) => r.name === "Procyon")) return "Procyon"; + + return null; +} + +export function oppositeNation(nation: Nation): Nation { + return nation === "Capella" ? "Procyon" : "Capella"; +} diff --git a/src/systems/poll.ts b/src/systems/poll.ts new file mode 100644 index 0000000..c15c5d5 --- /dev/null +++ b/src/systems/poll.ts @@ -0,0 +1,266 @@ +import { + EmbedBuilder, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + TextChannel, + GuildMember, +} from "discord.js"; +import { PollState, VoteEntry, Nation, TGSlot } from "../types"; +import { cfg } from "./config"; +import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis"; +import { getActiveCharacter, getCharacterByName } from "./characters"; +import { resolveNation } from "./nations"; +import { getEntry, getBringer } from "./wrank"; +import { nowFormatted } from "./messages"; + +// ─── Poll state ─────────────────────────────────────────────────────────────── +export const polls: Map = new Map(); + +const publicOverrides: Map = new Map(); +const ephemeralOverrides: Map = new Map(); + +export function setPublicOverride(userId: string, voteType: "yes" | "no", message: string): void { + const e = publicOverrides.get(userId) ?? {}; + e[voteType] = message; + publicOverrides.set(userId, e); +} +export function clearPublicOverride(userId: string, voteType?: "yes" | "no"): void { + if (!voteType) { publicOverrides.delete(userId); return; } + const e = publicOverrides.get(userId); + if (e) delete e[voteType]; +} +export function setEphemeralOverride(userId: string, voteType: "yes" | "no", message: string): void { + const e = ephemeralOverrides.get(userId) ?? {}; + e[voteType] = message; + ephemeralOverrides.set(userId, e); +} +export function clearEphemeralOverride(userId: string, voteType?: "yes" | "no"): void { + if (!voteType) { ephemeralOverrides.delete(userId); return; } + const e = ephemeralOverrides.get(userId); + if (e) delete e[voteType]; +} +export function getPublicOverride(userId: string, voteType: "yes" | "no"): string | undefined { + return publicOverrides.get(userId)?.[voteType]; +} +export function getEphemeralOverride(userId: string, voteType: "yes" | "no"): string | undefined { + return ephemeralOverrides.get(userId)?.[voteType]; +} +export function resetPollOverrides(): void { + publicOverrides.clear(); + ephemeralOverrides.clear(); +} + +// ─── Character display ──────────────────────────────────────────────────────── +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; + + let wrank = ""; + if (wRankEntry) { + const goal = cfg("wRankGoal"); + const isDone = wRankEntry.tgCount >= goal; + const rank = wRankEntry.currentRank; + const prev = wRankEntry.previousRank; + const delta = prev !== undefined ? rank - prev : 0; + // W.Rank emoji with text fallback + const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`; + const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`); + + // Delta arrows with text fallback + let deltaStr = ""; + if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`; + else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`; + else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`; + + wrank = `${rankStr}${deltaStr}`; + } + + const classStr = entry.characterClass + ? (getClassEmoji(entry.characterClass) || entry.characterClass) + : ""; + + const levelStr = entry.characterLevel && cfg("showLevelInMessages" as any) + ? `${entry.characterLevel}` + : ""; + + let row = format + .replace("{wrank}", wrank) + .replace("{class}", classStr) + .replace("{level}", levelStr) + .replace("{name}", entry.characterName ?? entry.displayName) + .replace(/\s+/g, " ") + .trim(); + + // Bringer title — independent of W.Rank so override always shows + if (nation && entry.usermapKey) { + const bringer = getBringer(nation); + if (bringer && bringer === entry.usermapKey) { + const emoji = nation === "Capella" + ? (getEmoji("luminous_bringer") || "🔆") + : (getEmoji("storm_bringer") || "⚡"); + const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer"; + row += ` · ${emoji} **${title}**`; + } + } + + if (entry.borrowedFrom) { + row += ` ${getEmoji("borrowed") || "🔗"}`; + } + + if (showNationEmoji && nation) row = `${getNationEmoji(nation)} ${row}`; + + return row; +} + +// ─── Embed building ─────────────────────────────────────────────────────────── +export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBuilder { + const yesByNation = { Capella: [] as VoteEntry[], Procyon: [] as VoteEntry[] }; + const noVoters: VoteEntry[] = []; + const allMessages: { entry: VoteEntry; voteType: "yes" | "no" }[] = []; + const showNoInline = (cfg as any)("showNoInNationField") ?? false; + + for (const entry of state.yes.values()) { + const nation = entry.characterNation ?? "Capella"; + yesByNation[nation].push(entry); + allMessages.push({ entry, voteType: "yes" }); + } + for (const entry of state.no.values()) { + noVoters.push(entry); + allMessages.push({ entry, voteType: "no" }); + } + + const capellaEmoji = getEmoji("capella"); + const procyonEmoji = getEmoji("procyon"); + + const formatNationField = (nation: Nation): string => { + const yesEntries = yesByNation[nation]; + const noEntries = showNoInline + ? noVoters.filter((e) => e.characterNation === nation) + : []; + const lines = [ + ...yesEntries.map((e) => formatCharRow(e)), + ...noEntries.map((e) => `❌ ${formatCharRow(e)}`), + ]; + return lines.length > 0 ? lines.join("\n") : "—"; + }; + + const formatMessages = (): string => { + if (allMessages.length === 0) return ""; + return allMessages + .map((m) => { + const name = m.entry.characterName ?? m.entry.displayName; + const prefix = m.voteType === "no" ? "✗ " : "✓ "; + const msg = m.entry.publicMessage ? ` — ${m.entry.publicMessage}` : ""; + return `${prefix}${name} · ${m.entry.votedAt}${msg}`; + }) + .join("\n"); + }; + + const locked = state.locked; + const confirmed = state.confirmed; + + const color = + confirmed === "yes" ? 0x57f287 : + confirmed === "no" ? 0xed4245 : + locked ? 0x888888 : + 0xe8a317; + + // Title with nation + no counts (hidden when confirmed or locked) + const counts = !locked && confirmed === null + ? ` ${capellaEmoji} ${yesByNation.Capella.length} ${procyonEmoji} ${yesByNation.Procyon.length}` + : ""; + const statusSuffix = + locked ? " 🔒" : + confirmed === "yes" ? " ✅" : + confirmed === "no" ? " ❌" : ""; + + const title = `⚔️ TG — ${state.slot}:00${counts}${statusSuffix}`; + + const embed = new EmbedBuilder() + .setTitle(title) + .setColor(color) + .addFields( + { name: `${capellaEmoji} Capella (${yesByNation.Capella.length})`, value: formatNationField("Capella"), inline: false }, + { name: "\u200b", value: "\u200b", inline: false }, + { name: `${procyonEmoji} Procyon (${yesByNation.Procyon.length})`, value: formatNationField("Procyon"), inline: false }, + ) + .setTimestamp(); + + const msgSection = formatMessages(); + if (msgSection) { + embed.addFields({ name: "\u200b", value: msgSection, inline: false }); + } + + let footer: string; + if (confirmed === "yes") footer = cfg("confirmYesMessage"); + else if (confirmed === "no") footer = cfg("confirmNoMessage"); + else if (locked) footer = overrideLockMsg ?? cfg("lockMessage"); + else footer = `❌ ${noVoters.length} • Vote updates live • Anyone can vote • /tg switch to change character`; + embed.setFooter({ text: footer }); + + return embed; +} + +export function buildButtons(disabled: boolean): ActionRowBuilder { + const yesBtn = new ButtonBuilder() + .setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled); + const noBtn = new ButtonBuilder() + .setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled); + return new ActionRowBuilder().addComponents(yesBtn, noBtn); +} + +export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise { + const state = polls.get(slot); + if (!state?.messageId) return; + try { + const msg = await channel.messages.fetch(state.messageId); + const disabled = state.locked || state.confirmed !== null; + await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] }); + } catch (err) { + console.error("Failed to update poll message:", err); + } +} + +export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { + resetPollOverrides(); + const { clearSessionBorrows } = require("./borrow"); + clearSessionBorrows(); + + const state: PollState = { + messageId: null, slot: slot.tgHour, + yes: new Map(), no: new Map(), + locked: false, confirmed: null, + }; + polls.set(slot.tgHour, state); + const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] }); + state.messageId = msg.id; + console.log(`[${new Date().toISOString()}] Poll posted for ${slot.tgHour}:00.`); +} + +export function createVoteEntry( + userId: string, + member: GuildMember, + usermapKey: string | null, + discordUsername: string +): Omit { + const serverNickname = member.nickname ?? null; + const globalNickname = member.user.globalName ?? null; + const displayName = serverNickname ?? globalNickname ?? discordUsername; + + const { getEffectiveCharacter } = require("./borrow"); + const { char, borrowedFrom: bf } = usermapKey + ? getEffectiveCharacter(usermapKey) + : { char: null, borrowedFrom: null }; + + return { + usermapKey: usermapKey ?? (undefined as any), + displayName, + characterName: char?.name, + characterClass: char?.class, + characterLevel: char?.level, + characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined), + borrowedFrom: bf ?? undefined, + }; +} \ No newline at end of file diff --git a/src/systems/scores.ts b/src/systems/scores.ts new file mode 100644 index 0000000..87cf1a1 --- /dev/null +++ b/src/systems/scores.ts @@ -0,0 +1,90 @@ +import { TGScore, Nation, ClassKey } from "../types"; +import { cfg } from "./config"; +import { upsertScore, todayString } from "./history"; +import { recordScore } from "./wrank"; + +// Normalize a slot string to a 24h integer hour +// Accepts: "20", "8", "8pm", "20:00", "midnight", "midday", "noon" +export function normalizeSlot(input: string): number | null { + const s = input.trim().toLowerCase(); + if (s === "midnight") return 0; + if (s === "midday" || s === "noon") return 12; + + const pmMatch = s.match(/^(\d{1,2})pm$/); + if (pmMatch) { + const h = parseInt(pmMatch[1]); + return h === 12 ? 12 : h + 12; + } + + const amMatch = s.match(/^(\d{1,2})am$/); + if (amMatch) { + const h = parseInt(amMatch[1]); + return h === 12 ? 0 : h; + } + + const colonMatch = s.match(/^(\d{1,2}):\d{2}$/); + if (colonMatch) return parseInt(colonMatch[1]); + + const numMatch = s.match(/^(\d{1,2})$/); + if (numMatch) return parseInt(numMatch[1]); + + return null; +} + +// Detect which slot a submission belongs to based on current time +export function detectSlot(): number | null { + const slots = cfg("slots").filter((s) => s.active); + const windowMs = cfg("scoreWindowHours") * 60 * 60 * 1000; + const durationMs = cfg("tgDurationMinutes") * 60 * 1000; + const now = Date.now(); + + for (const slot of slots) { + const today = new Date(); + const tgTime = new Date(today); + tgTime.setHours(slot.tgHour, 0, 0, 0); + const closeTime = tgTime.getTime() + durationMs; + const windowEnd = closeTime + windowMs; + + if (now >= closeTime && now <= windowEnd) { + return slot.tgHour; + } + } + return null; +} + +export interface ScoreSubmission { + usermapKey: string; + characterName: string; + cls: ClassKey; + nation: Nation; + pts: number; + slot: number; + date?: string; + atk?: number; + def?: number; + heal?: number; + submittedByOfficer: boolean; +} + +export function submitScore(sub: ScoreSubmission): void { + const date = sub.date ?? todayString(); + const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`; + + const score: TGScore = { + usermapKey: sub.usermapKey, + characterName: sub.characterName, + class: sub.cls, + nation: sub.nation, + pts: sub.pts, + atk: sub.atk, + def: sub.def, + heal: sub.heal, + submittedAt: new Date().toISOString(), + slot: sub.slot, + date, + submittedByOfficer: sub.submittedByOfficer, + }; + + upsertScore(score); + recordScore(sub.usermapKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); +} diff --git a/src/systems/slots.ts b/src/systems/slots.ts new file mode 100644 index 0000000..00bf851 --- /dev/null +++ b/src/systems/slots.ts @@ -0,0 +1,57 @@ +import cron from "node-cron"; +import { Client } from "discord.js"; +import { cfg } from "./config"; +import { TGSlot } from "../types"; + +type PollCallback = (slot: TGSlot) => Promise; +type CloseCallback = (slot: TGSlot) => Promise; + +let _scheduledTasks: cron.ScheduledTask[] = []; + +export function scheduleSlots( + client: Client, + onPollOpen: PollCallback, + onPollClose: CloseCallback +): void { + // Clear existing schedules + _scheduledTasks.forEach((t) => t.stop()); + _scheduledTasks = []; + + const tz = process.env.TZ ?? "Etc/GMT-2"; + const slots = cfg("slots").filter((s) => s.active); + + for (const slot of slots) { + // Parse poll open time + const [openHour, openMin] = slot.pollOpens.split(":").map(Number); + + // Schedule poll open + const openTask = cron.schedule( + `${openMin} ${openHour} * * *`, + () => onPollOpen(slot), + { timezone: tz } + ); + _scheduledTasks.push(openTask); + + // Schedule poll close (tgHour + closesAfter minutes) + const closeMinTotal = slot.tgHour * 60 + slot.closesAfter; + const closeHour = Math.floor(closeMinTotal / 60) % 24; + const closeMin = closeMinTotal % 60; + + const closeTask = cron.schedule( + `${closeMin} ${closeHour} * * *`, + () => onPollClose(slot), + { timezone: tz } + ); + _scheduledTasks.push(closeTask); + } + + // Weekly reset — Monday 00:00 + const resetTask = cron.schedule("0 0 * * 1", () => { + const { resetWeek } = require("./wrank"); + resetWeek(); + console.log("W.Rank weekly reset complete."); + }, { timezone: tz }); + _scheduledTasks.push(resetTask); + + console.log(`Scheduled ${slots.length} slot(s).`); +} diff --git a/src/systems/users.ts b/src/systems/users.ts new file mode 100644 index 0000000..9338e87 --- /dev/null +++ b/src/systems/users.ts @@ -0,0 +1,40 @@ +import { GuildMember } from "discord.js"; +import { ResolvedUser } from "../types"; +import { getUsermapEntry } from "./messages"; +import { getActiveCharacter } from "./characters"; + +// Resolves a full user context from a GuildMember + discord username +export async function resolveUser(member: GuildMember): Promise { + const discordUsername = member.user.username; + const serverNickname = member.nickname ?? null; + 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; + + return { + userId: member.user.id, + discordUsername, + usermapKey, + displayName, + serverNickname, + globalNickname, + aliases, + activeCharacter: activeChar, + }; +} + +// Resolve a user by their usermap key (for officer commands using arg) +export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType } { + return { + usermapKey: key, + activeCharacter: getActiveCharacter(key), + }; +} + +export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean { + return member.roles.cache.some((r) => officerRoles.includes(r.name)); +} diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts new file mode 100644 index 0000000..26861ce --- /dev/null +++ b/src/systems/wrank.ts @@ -0,0 +1,156 @@ +import fs from "fs"; +import path from "path"; +import { WRankData, WRankWeek, WRankEntry, Nation, ClassKey } from "../types"; +import { cfg } from "./config"; + +const WRANK_PATH = path.join(__dirname, "../../data/wrank.json"); +let _data: WRankData = {}; + +export function loadWRank(): void { + try { _data = JSON.parse(fs.readFileSync(WRANK_PATH, "utf8")); } + catch { _data = {}; } +} + +function saveWRank(): void { + fs.writeFileSync(WRANK_PATH, JSON.stringify(_data, null, 2)); +} + +export function getWeekKey(date: Date = new Date()): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const week = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} + +function ensureWeek(weekKey: string): WRankWeek { + if (!_data[weekKey]) { + _data[weekKey] = { + weekKey, + entries: { capella: [], procyon: [] }, + scoreIndex: {}, + bringer: { capella: null, procyon: null }, + }; + } + return _data[weekKey]; +} + +export function getCurrentWeek(): WRankWeek { + return ensureWeek(getWeekKey()); +} + +export function getWeek(weekKey: string): WRankWeek | null { + return _data[weekKey] ?? null; +} + +// Add or update a score submission for a player +export function recordScore( + usermapKey: string, + characterName: string, + cls: ClassKey, + nation: Nation, + pts: number, + historyKey: string // e.g. "2026-05-31-20" +): void { + const weekKey = getWeekKey(); + const week = ensureWeek(weekKey); + const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; + + const existing = list.find((e) => e.usermapKey === usermapKey); + + if (existing) { + // Check if this slot was already counted + const alreadyCounted = week.scoreIndex[usermapKey]?.includes(historyKey); + if (!alreadyCounted) { + existing.weeklyPoints += pts; + existing.tgCount += 1; + } else { + // Overwrite: recalculate by removing old pts for this slot + // We'll just set the new pts — full recalc would require reading history + // For now, simple overwrite of total is handled at score submission level + existing.weeklyPoints = existing.weeklyPoints - (existing.weeklyPoints / existing.tgCount) + pts; + } + existing.characterName = characterName; + existing.class = cls; + existing.nation = nation; + } else { + list.push({ + usermapKey, + characterName, + class: cls, + nation, + weeklyPoints: pts, + tgCount: 1, + currentRank: 0, + previousRank: undefined, + }); + } + + // Update score index + if (!week.scoreIndex[usermapKey]) week.scoreIndex[usermapKey] = []; + if (!week.scoreIndex[usermapKey].includes(historyKey)) { + week.scoreIndex[usermapKey].push(historyKey); + } + + recomputeRanks(week, nation); + updateBringer(week); + saveWRank(); +} + +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)!; + live.previousRank = live.currentRank || undefined; + live.currentRank = i + 1; + }); +} + +function updateBringer(week: WRankWeek): void { + const goal = cfg("wRankGoal"); + for (const nation of ["capella", "procyon"] as const) { + // Don't overwrite manual override + if (nation === "capella" && week.bringer.capellaOverride) continue; + if (nation === "procyon" && week.bringer.procyonOverride) continue; + + const qualified = week.entries[nation] + .filter((e) => e.tgCount >= goal) + .sort((a, b) => a.currentRank - b.currentRank); + week.bringer[nation] = qualified[0]?.usermapKey ?? null; + } +} + +export function setBringerOverride(nation: Nation, usermapKey: string): void { + const week = ensureWeek(getWeekKey()); + if (nation === "Capella") week.bringer.capellaOverride = usermapKey; + else week.bringer.procyonOverride = usermapKey; + saveWRank(); +} + +export function clearBringerOverride(nation: Nation): void { + const week = ensureWeek(getWeekKey()); + if (nation === "Capella") delete week.bringer.capellaOverride; + else delete week.bringer.procyonOverride; + updateBringer(week); + saveWRank(); +} + +export function getBringer(nation: Nation): string | null { + const week = getCurrentWeek(); + if (nation === "Capella") return week.bringer.capellaOverride ?? week.bringer.capella; + return week.bringer.procyonOverride ?? week.bringer.procyon; +} + +export function getEntry(usermapKey: 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; +} + +// Called every Monday 00:00 by cron +export function resetWeek(): void { + // Week is already archived in _data by weekKey — just ensure next week exists + ensureWeek(getWeekKey(new Date())); + saveWRank(); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fcd59bf --- /dev/null +++ b/src/types.ts @@ -0,0 +1,275 @@ +// ─── Enums ─────────────────────────────────────────────────────────────────── + +export type Nation = "Capella" | "Procyon"; + +export type ClassKey = + | "BL" // Blader + | "FB" // Force Blader + | "FS" // Force Shielder + | "FA" // Force Archer + | "FG" // Force Gunner + | "GL" // Gladiator + | "DM" // Dark Mage + | "WI" // Wizard + | "WA"; // Warrior + +export const CLASS_NAMES: Record = { + BL: "Blader", + FB: "Force Blader", + FS: "Force Shielder", + FA: "Force Archer", + FG: "Force Gunner", + GL: "Gladiator", + DM: "Dark Mage", + WI: "Wizard", + WA: "Warrior", +}; + +// ─── Character ─────────────────────────────────────────────────────────────── + +export interface CharacterStats { + str?: number; + int?: number; + dex?: number; + honorRank?: number; + honorPoints?: number; + custom?: Record; +} + +export interface Character { + name: string; + class: ClassKey; + level: number; + nation: Nation; + active: boolean; + stats?: CharacterStats; + sharedWith?: string[]; // usermap keys with permanent access +} + +export interface CharacterMap { + [usermapKey: string]: { + characters: Character[]; + }; +} + +export interface BorrowRequest { + requesterKey: string; // who wants to borrow + ownerKey: string; // who owns the character + charName: string; // which character + requestedAt: number; // timestamp for expiry +} + +// ─── Account ───────────────────────────────────────────────────────────────── + +export interface AccountData { + collection?: Record; + animaMastery?: Record; + custom?: Record; +} + +export interface AccountMap { + [usermapKey: string]: AccountData; +} + +// ─── Usermap ───────────────────────────────────────────────────────────────── + +export interface UsermapEntry { + file: string; + aliases: string[]; +} + +export interface Usermap { + [discordUsername: string]: UsermapEntry | string; // string = legacy format +} + +// ─── TG Slots ──────────────────────────────────────────────────────────────── + +export interface TGSlot { + tgHour: number; // 20 + pollOpens: string; // "10:00" + closesAfter: number; // minutes after tgHour (default 35) + active: boolean; +} + +// ─── Poll ──────────────────────────────────────────────────────────────────── + +export interface VoteEntry { + usermapKey: string; + displayName: string; // server nickname → global nickname → username + characterName?: string; // active character name at time of vote + characterClass?: ClassKey; // snapshotted + characterLevel?: number; // snapshotted + characterNation?: Nation; // snapshotted at vote time + votedAt: string; // HH:MM formatted + previousYesAt?: string; + previousNoAt?: string; + publicMessage?: string; // resolved from message system or officer override + 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 +} + +export interface PollState { + messageId: string | null; + slot: number; + yes: Map; // userId → VoteEntry + no: Map; + locked: boolean; + confirmed: "yes" | "no" | null; + lockMessage?: string; + confirmMessage?: string; +} + +// ─── Scores ────────────────────────────────────────────────────────────────── + +export interface TGScore { + usermapKey: string; + characterName: string; + class: ClassKey; + nation: Nation; // snapshotted at submission time + pts: number; + atk?: number; + def?: number; + heal?: number; + submittedAt: string; // ISO timestamp + slot: number; // TG hour + date: string; // YYYY-MM-DD + submittedByOfficer: boolean; +} + +// ─── TG Result ─────────────────────────────────────────────────────────────── + +export interface NationKD { + k: number; + d: number; +} + +export interface TGResult { + slot: number; + date: string; // YYYY-MM-DD + closedAt?: string; // ISO timestamp + confirmed: boolean; + nationKD: { + source: Nation; // which nation is source of truth + capella: NationKD; + procyon: NationKD; + }; + scores: TGScore[]; +} + +// ─── W.Rank ────────────────────────────────────────────────────────────────── + +export interface WRankEntry { + usermapKey: string; + characterName: string; // snapshotted + class: ClassKey; // snapshotted + nation: Nation; // snapshotted + weeklyPoints: number; // cumulative pts this week + tgCount: number; // number of slots with submission this week + currentRank: number; // computed after each submission + previousRank?: number; // before latest recomputation +} + +export interface WRankWeek { + weekKey: string; // "2026-W22" + entries: { + capella: WRankEntry[]; + procyon: WRankEntry[]; + }; + scoreIndex: { + [usermapKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"] + }; + bringer: { + capella: string | null; // usermapKey of bringer, null if none qualified + procyon: string | null; + capellaOverride?: string; // manually set by officer + procyonOverride?: string; + }; +} + +export interface WRankData { + [weekKey: string]: WRankWeek; +} + +// ─── Bringer ───────────────────────────────────────────────────────────────── + +export interface BringerState { + currentWeek: string; // "2026-W22" + capella: string | null; // usermapKey + procyon: string | null; + capellaOverride?: string; + procyonOverride?: string; +} + +// ─── Config ────────────────────────────────────────────────────────────────── + +export interface BotConfig { + officerRoles?: string[]; + configRoles?: string[]; + tagRoles?: string[]; + lockMessage?: string; + confirmYesMessage?: string; + confirmNoMessage?: string; + pollChannelId?: string; + resultsChannelId?: string; + scoreChannelId?: string; + slots?: TGSlot[]; + scoreWindowHours?: number; + tgDurationMinutes?: number; + nationSource?: Nation; + wRankPostOnReset?: boolean; + wRankGoal?: number; // default 7 + wRankYellowColor?: string; // hex + wRankGrayColor?: string; // hex + deltaUpColor?: string; // hex + deltaDownColor?: string; // hex + stormBringerColor?: string; // hex + luminousBringerColor?: string; // hex + showClassInMessages?: boolean; + showLevelInMessages?: boolean; + charDisplayFormat?: string; // "{wrank} {class} {name}" + showNationTotalsInHeader?: boolean; + showNoInNationField?: boolean; + borrowRequestExpiryMs?: number; // 0 = never expire (default) +} + +// ─── Messages ──────────────────────────────────────────────────────────────── + +export interface MessageEntry { + clicks: number; + message?: string; // single message (legacy) + messages?: string[]; // array of messages + random?: boolean; + days?: Record; + dates?: Record; +} + +export interface MessageBlock { + yes: MessageEntry[]; + no: MessageEntry[]; + users?: Record; +} + +export interface MessagesFile { + public: MessageBlock; + ephemeral: MessageBlock; +} + +// ─── Emojis ────────────────────────────────────────────────────────────────── + +export interface EmojiMap { + [key: string]: string; // e.g. "capella" → "<:Capella:1477082112560726238>" +} + +// ─── Interaction context ───────────────────────────────────────────────────── + +export interface ResolvedUser { + userId: string; + discordUsername: string; // interaction.user.username + usermapKey: string | null; // resolved from usermap + displayName: string; // server nickname → global nickname → username + serverNickname: string | null; + globalNickname: string | null; + aliases: string[]; + activeCharacter: Character | null; +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..b5395e3 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,18 @@ +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"); + +export async function replyAndDelete( + interaction: ChatInputCommandInteraction | 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); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..83a6139 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}