initial commit

This commit is contained in:
Nuno Duque Nunes 2026-06-01 13:36:51 +01:00
commit 1446cd10fc
79 changed files with 4304 additions and 0 deletions

7
.env Normal file
View file

@ -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

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
DISCORD_TOKEN=your_bot_token_here
CHANNEL_ID=your_channel_id_here

11
Dockerfile Normal file
View file

@ -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"]

1
data/accounts.json Normal file
View file

@ -0,0 +1 @@
{}

5
data/bringer.json Normal file
View file

@ -0,0 +1,5 @@
{
"currentWeek": "",
"capella": null,
"procyon": null
}

107
data/characters.json Normal file
View file

@ -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
}
]
}
}

4
data/config.json Normal file
View file

@ -0,0 +1,4 @@
{
"showLevelInMessages": true,
"showClassInMessages": true
}

View file

@ -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
}
]
}

View file

@ -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
}
]
}

View file

@ -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
}
]
}

View file

@ -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
}
]
}

View file

@ -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
}
]
}

View file

@ -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
}
]
}

View file

@ -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
}
]
}

13
data/usermap.json Normal file
View file

@ -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"
}

16
data/wrank.json Normal file
View file

@ -0,0 +1,16 @@
{
"2026-W23": {
"weekKey": "2026-W23",
"entries": {
"capella": [],
"procyon": []
},
"scoreIndex": {},
"bringer": {
"capella": null,
"procyon": null,
"procyonOverride": "flash",
"capellaOverride": "zephyr"
}
}
}

14
docker-compose.yml Normal file
View file

@ -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

43
messages/emojis.json Normal file
View file

@ -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": "🔗"
}

44
messages/global.json Normal file
View file

@ -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."] }
]
}
}

16
messages/users/ayana.json Normal file
View file

@ -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": {}
}

18
messages/users/dey.json Normal file
View file

@ -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": {}
}

30
messages/users/flash.json Normal file
View file

@ -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."] }
]
}
}

View file

@ -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": {}
}

18
messages/users/keira.json Normal file
View file

@ -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": {}
}

20
messages/users/marin.json Normal file
View file

@ -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": {}
}

19
messages/users/sean.json Normal file
View file

@ -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": {}
}

View file

@ -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": {}
}

6
nodemon.json Normal file
View file

@ -0,0 +1,6 @@
{
"watch": ["src"],
"ext": "ts",
"ignore": ["src/**/*.spec.ts"],
"exec": "ts-node src/index.ts"
}

22
package.json Normal file
View file

@ -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"
}
}

297
src/commands/tg.ts Normal file
View file

@ -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<void> {
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";

211
src/commands/tgConfig.ts Normal file
View file

@ -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<void> {
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."); }
}
}

109
src/handlers/buttons.ts Normal file
View file

@ -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<string, { yes: number; no: number }>();
export async function handleButton(interaction: ButtonInteraction): Promise<void> {
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();
}

View file

@ -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<void> {
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 {}
}
}

72
src/index.ts Normal file
View file

@ -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<void> {
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<void> {
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<void> {
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);

View file

@ -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<void> {
const nation = interaction.options.getString("nation", true) as Nation;
clearBringerOverride(nation);
return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`);
}

View file

@ -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<void> {
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.`);
}

View file

@ -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<void> {
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<void> {
await acceptBorrow(interaction, ownerKey, requesterKey);
}
async function acceptBorrow(
interaction: ChatInputCommandInteraction | ButtonInteraction,
ownerKey: string,
requesterKey: string
): Promise<void> {
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);
}

View file

@ -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<void> {
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.`);
}

View file

@ -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<void> {
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}»**.`);
}

View file

@ -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<void> {
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<void> {
await declineBorrow(interaction, ownerKey, requesterKey);
}
async function declineBorrow(
interaction: ChatInputCommandInteraction | ButtonInteraction,
ownerKey: string,
requesterKey: string
): Promise<void> {
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);
}

View file

@ -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<void> {
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.`);
}

View file

@ -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<void> {
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.`);
}

View file

@ -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<void> {
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}**.`);
}

View file

@ -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<void> {
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}»**.`);
}

View file

@ -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<void> {
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<void> {
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.`);
}

View file

@ -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<void> {
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}`);
}

View file

@ -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<void> {
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<void> {
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);
}

View file

@ -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<void> {
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<void> {
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}**.`);
}

View file

@ -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<void> {
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];
}

View file

@ -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<void> {
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<string, Message>;
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}>.`);
}

View file

@ -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<void> {
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.");
}

View file

@ -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<void> {
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<string, UsermapEntry | string> = {};
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)` : ""}.`);
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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.`);
}

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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!");
}

View file

@ -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<void> {
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.`);
}

View file

@ -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<void> {
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.");
}

View file

@ -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<void> {
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.");
}

View file

@ -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<void> {
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}`
);
}

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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);
}

View file

@ -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<void> {
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).`);
}

102
src/subcommands/switch.ts Normal file
View file

@ -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<void> {
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<string, any>) => {
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}.`);
}

158
src/systems/borrow.ts Normal file
View file

@ -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<string, BorrowRequest> = new Map(); // key: `${ownerKey}:${requesterKey}`
// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start
const sessionBorrows: Map<string, { ownerKey: string; charName: string }> = new Map();
// DM message IDs for updating borrow request messages
const borrowDmMessages: Map<string, { channelId: string; messageId: string }> = 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<void> {
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<ButtonBuilder>().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<void> {
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 };
}

111
src/systems/characters.ts Normal file
View file

@ -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<Character, "active">): 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<AccountData>): void {
if (!_accounts[usermapKey]) _accounts[usermapKey] = {};
Object.assign(_accounts[usermapKey], data);
saveAccounts();
}

66
src/systems/config.ts Normal file
View file

@ -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<BotConfig> {
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<K extends keyof BotConfig>(key: K): Required<BotConfig>[K] {
return (_cfg[key] !== undefined ? _cfg[key] : getDefaults()[key]) as Required<BotConfig>[K];
}
export function setCfg<K extends keyof BotConfig>(key: K, value: BotConfig[K]): void {
_cfg[key] = value;
saveConfig();
}
export function resetCfg<K extends keyof BotConfig>(key: K): void {
delete _cfg[key];
saveConfig();
}

27
src/systems/emojis.ts Normal file
View file

@ -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));
}

92
src/systems/history.ts Normal file
View file

@ -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);
}

164
src/systems/messages.ts Normal file
View file

@ -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<string, MessagesFile>;
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;
}

22
src/systems/nations.ts Normal file
View file

@ -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";
}

266
src/systems/poll.ts Normal file
View file

@ -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<number, PollState> = new Map();
const publicOverrides: Map<string, { yes?: string; no?: string }> = new Map();
const ephemeralOverrides: Map<string, { yes?: string; no?: string }> = 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<ButtonBuilder> {
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<ButtonBuilder>().addComponents(yesBtn, noBtn);
}
export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
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<void> {
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<VoteEntry, "votedAt" | "previousYesAt" | "previousNoAt" | "publicMessage"> {
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,
};
}

90
src/systems/scores.ts Normal file
View file

@ -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);
}

57
src/systems/slots.ts Normal file
View file

@ -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<void>;
type CloseCallback = (slot: TGSlot) => Promise<void>;
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).`);
}

40
src/systems/users.ts Normal file
View file

@ -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<ResolvedUser> {
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 <name> arg)
export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
return {
usermapKey: key,
activeCharacter: getActiveCharacter(key),
};
}
export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean {
return member.roles.cache.some((r) => officerRoles.includes(r.name));
}

156
src/systems/wrank.ts Normal file
View file

@ -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();
}

275
src/types.ts Normal file
View file

@ -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<ClassKey, string> = {
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<string, number>;
}
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<string, number>;
animaMastery?: Record<string, number>;
custom?: Record<string, number>;
}
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<string, VoteEntry>; // userId → VoteEntry
no: Map<string, VoteEntry>;
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<string, { messages: string[]; random?: boolean }>;
dates?: Record<string, { messages: string[]; random?: boolean }>;
}
export interface MessageBlock {
yes: MessageEntry[];
no: MessageEntry[];
users?: Record<string, { yes: MessageEntry[]; no: MessageEntry[] }>;
}
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;
}

18
src/utils.ts Normal file
View file

@ -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<void> {
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);
}
}

16
tsconfig.json Normal file
View file

@ -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"]
}