merge dev
23
.env
|
|
@ -1,7 +1,16 @@
|
|||
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
|
||||
DISCORD_TOKEN=MTUxMDk1OTgxNDYyMzEwNTA0NA.GNY7A9.Boq4MruKRqvo1UZ5JmsCkYN7q1xCTNKuqyh1oA
|
||||
POLL_CHANNEL_ID=1511006387293917355
|
||||
RESULTS_CHANNEL_ID=1511006410627088544
|
||||
SCORE_CHANNEL_ID=1511006435079884991
|
||||
CLIENT_ID=1510959814623105044
|
||||
GUILD_ID=1511006171681652858
|
||||
EPHEMERAL_DELETE_MS=0
|
||||
POLL_EPHEMERAL_ENABLED=false # voting messages (disabled during testing)
|
||||
COMMAND_EPHEMERAL_ENABLED=true # command outputs (always on)
|
||||
AUTO_VOTE_ON_CONFLICT_SWITCH=true
|
||||
IMPERSONATE_RESET_ON_POLL=false
|
||||
IMPERSONATE_INDICATOR=true
|
||||
RECLAIM_NOTIFY_BORROWER=true
|
||||
|
||||
# Emoji upload servers
|
||||
EMOJI_DONOR_GUILDS=1511903882224336926,1511904145810915449
|
||||
6
.gitignore
vendored
|
|
@ -14,6 +14,12 @@ data/bringer.json
|
|||
data/sessionPreferences.json
|
||||
data/tg-history/
|
||||
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
# Emoji data
|
||||
emoji-uploads/
|
||||
|
||||
>>>>>>> dev
|
||||
# Keep the data directory structure but not the contents
|
||||
!data/.gitkeep
|
||||
!data/tg-history/.gitkeep
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ RUN npm install
|
|||
COPY src/ ./src/
|
||||
COPY tsconfig.json ./
|
||||
|
||||
CMD ["npx", "ts-node", "src/index.ts"]
|
||||
CMD ["npx", "ts-node", "-r", "tsconfig-paths/register", "src/index.ts"]
|
||||
|
|
@ -16,7 +16,10 @@
|
|||
"class": "WI",
|
||||
"level": 79,
|
||||
"nation": "Procyon",
|
||||
"active": true
|
||||
"active": true,
|
||||
"sharedWith": [
|
||||
"invicjusz"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -38,7 +41,10 @@
|
|||
"class": "BL",
|
||||
"level": 79,
|
||||
"nation": "Capella",
|
||||
"active": true
|
||||
"active": true,
|
||||
"sharedWith": [
|
||||
"flash"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
26
data/poll-state.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[
|
||||
{
|
||||
"messageId": "1511906795667456040",
|
||||
"slot": 20,
|
||||
"yes": [
|
||||
[
|
||||
"164487045052497920",
|
||||
{
|
||||
"userKey": "flash",
|
||||
"displayName": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"characterClass": "WI",
|
||||
"characterLevel": 79,
|
||||
"characterNation": "Procyon",
|
||||
"discordId": "164487045052497920",
|
||||
"votedAt": "03:53",
|
||||
"previousNoAt": "03:53",
|
||||
"publicMessage": "Flash? Flash? Flash!!"
|
||||
}
|
||||
]
|
||||
],
|
||||
"no": [],
|
||||
"locked": false,
|
||||
"confirmed": null
|
||||
}
|
||||
]
|
||||
29
data/tg-history/2026-06-01-22.json
Normal 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": 1000,
|
||||
"submittedAt": "2026-06-01T22:05:28.186Z",
|
||||
"slot": 22,
|
||||
"date": "2026-06-01",
|
||||
"submittedByOfficer": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"entries": {
|
||||
"capella": [
|
||||
{
|
||||
"usermapKey": "zephyr",
|
||||
"userKey": "zephyr",
|
||||
"characterName": "XefronYokuda",
|
||||
"class": "FA",
|
||||
"nation": "Capella",
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"previousRank": 3
|
||||
},
|
||||
{
|
||||
"usermapKey": "dey",
|
||||
"userKey": "dey",
|
||||
"characterName": "«Deystroyer»",
|
||||
"class": "BL",
|
||||
"nation": "Capella",
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"previousRank": 2
|
||||
},
|
||||
{
|
||||
"usermapKey": "keira",
|
||||
"userKey": "keira",
|
||||
"characterName": "«Keira»",
|
||||
"class": "WI",
|
||||
"nation": "Capella",
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
"previousRank": 1
|
||||
},
|
||||
{
|
||||
"usermapKey": "sean",
|
||||
"userKey": "sean",
|
||||
"characterName": "»No.1«",
|
||||
"class": "FB",
|
||||
"nation": "Capella",
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
],
|
||||
"procyon": [
|
||||
{
|
||||
"usermapKey": "flash",
|
||||
"userKey": "flash",
|
||||
"characterName": "»Flash«",
|
||||
"class": "WI",
|
||||
"nation": "Procyon",
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
"previousRank": 1
|
||||
},
|
||||
{
|
||||
"usermapKey": "invicjusz",
|
||||
"userKey": "invicjusz",
|
||||
"characterName": "ElementalEnchant",
|
||||
"class": "FB",
|
||||
"nation": "Procyon",
|
||||
|
|
@ -66,7 +66,7 @@
|
|||
"previousRank": 3
|
||||
},
|
||||
{
|
||||
"usermapKey": "ayana",
|
||||
"userKey": "ayana",
|
||||
"characterName": "«MonkeyHunter»",
|
||||
"class": "DM",
|
||||
"nation": "Procyon",
|
||||
|
|
@ -112,8 +112,8 @@
|
|||
"bringer": {
|
||||
"capella": null,
|
||||
"procyon": null,
|
||||
"procyonOverride": "flash",
|
||||
"capellaOverride": "zephyr"
|
||||
"procyonOverride": "»Flash«",
|
||||
"capellaOverride": "XefronYokuda"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
services:
|
||||
tg-bot:
|
||||
tg-bot-dev:
|
||||
build:
|
||||
context: /opt/docker/tg-bot-ts
|
||||
image: tg-bot-ts:latest
|
||||
container_name: tg-bot-ts
|
||||
context: /opt/docker/tg-bot-ts-dev
|
||||
image: tg-bot-ts-dev:latest
|
||||
container_name: tg-bot-ts-dev
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- /opt/docker/tg-bot-ts/.env
|
||||
- /opt/docker/tg-bot-ts-dev/.env
|
||||
volumes:
|
||||
- /opt/docker/tg-bot-ts/src:/app/src
|
||||
- /opt/docker/tg-bot-ts/data:/app/data
|
||||
- /opt/docker/tg-bot-ts/messages:/app/messages
|
||||
- /opt/docker/tg-bot-ts/tsconfig.json:/app/tsconfig.json
|
||||
- /opt/docker/tg-bot-ts-dev/src:/app/src
|
||||
- /opt/docker/tg-bot-ts-dev/data:/app/data
|
||||
- /opt/docker/tg-bot-ts-dev/scripts:/app/scripts
|
||||
- /opt/docker/tg-bot-ts-dev/messages:/app/messages
|
||||
- /opt/docker/tg-bot-ts-dev/emoji-uploads:/app/emoji-uploads
|
||||
- /opt/docker/tg-bot-ts-dev/tsconfig.json:/app/tsconfig.json
|
||||
- /opt/docker/tg-bot-ts-dev/data/sessionPreferences.json:/app/data/sessionPreferences.json
|
||||
|
|
|
|||
BIN
emoji-uploads/bl.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/borrowed.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
emoji-uploads/capella.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
emoji-uploads/dm.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
emoji-uploads/fa.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
emoji-uploads/fb.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
emoji-uploads/fg.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
emoji-uploads/fs.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
emoji-uploads/gl.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/kd.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
emoji-uploads/luminous_bringer.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
emoji-uploads/procyon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
emoji-uploads/rank.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
emoji-uploads/score.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
emoji-uploads/storm_bringer.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
emoji-uploads/wa.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/wi.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
emoji-uploads/wrank_1.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
emoji-uploads/wrank_1_gold.png
Normal file
|
After Width: | Height: | Size: 945 B |
BIN
emoji-uploads/wrank_2.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emoji-uploads/wrank_2_gold.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
emoji-uploads/wrank_3.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
emoji-uploads/wrank_3_gold.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
emoji-uploads/wrank_4.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
emoji-uploads/wrank_4_gold.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
emoji-uploads/wrank_5.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
emoji-uploads/wrank_5_gold.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emoji-uploads/wrank_down.png
Normal file
|
After Width: | Height: | Size: 283 B |
BIN
emoji-uploads/wrank_down_1.png
Normal file
|
After Width: | Height: | Size: 956 B |
BIN
emoji-uploads/wrank_down_2.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
emoji-uploads/wrank_down_3.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
emoji-uploads/wrank_down_4.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
emoji-uploads/wrank_down_5.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
emoji-uploads/wrank_up.png
Normal file
|
After Width: | Height: | Size: 269 B |
BIN
emoji-uploads/wrank_up_1.png
Normal file
|
After Width: | Height: | Size: 943 B |
BIN
emoji-uploads/wrank_up_2.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
emoji-uploads/wrank_up_3.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
emoji-uploads/wrank_up_4.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
emoji-uploads/wrank_up_5.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
<<<<<<< HEAD
|
||||
"capella": "<:Capella:1477082112560726238>",
|
||||
"procyon": "<:Procyon:1477082175181426738>",
|
||||
"bl": "<:bl:1510827912767475742>",
|
||||
|
|
@ -41,3 +42,45 @@
|
|||
"rank": "<:rank:1510999225998249995>",
|
||||
"borrowed": "<:share:1511000283973550080>"
|
||||
}
|
||||
=======
|
||||
"bl": "<:bl:1511906439516651561>",
|
||||
"borrowed": "<:borrowed:1511906443245391944>",
|
||||
"capella": "<:capella:1511906447167062137>",
|
||||
"dm": "<:dm:1511906450866180126>",
|
||||
"fa": "<:fa:1511906454506967242>",
|
||||
"fb": "<:fb:1511906458231377950>",
|
||||
"fg": "<:fg:1511906461977022605>",
|
||||
"fs": "<:fs:1511906465798029423>",
|
||||
"gl": "<:gl:1511906470684524594>",
|
||||
"kd": "<:kd:1511906474497146983>",
|
||||
"luminous_bringer": "<:luminous_bringer:1511906480184492263>",
|
||||
"procyon": "<:procyon:1511906483993055295>",
|
||||
"rank": "<:rank:1511906488380293180>",
|
||||
"score": "<:score:1511906491903250525>",
|
||||
"storm_bringer": "<:storm_bringer:1511906496097554594>",
|
||||
"wa": "<:wa:1511906499889467492>",
|
||||
"wi": "<:wi:1511906503647563807>",
|
||||
"wrank_1": "<:wrank_1:1511906507485085736>",
|
||||
"wrank_1_gold": "<:wrank_1_gold:1511906510806978742>",
|
||||
"wrank_2": "<:wrank_2:1511906514745430217>",
|
||||
"wrank_2_gold": "<:wrank_2_gold:1511906518386212864>",
|
||||
"wrank_3": "<:wrank_3:1511906522265944154>",
|
||||
"wrank_3_gold": "<:wrank_3_gold:1511906526204530690>",
|
||||
"wrank_4": "<:wrank_4:1511906530692173915>",
|
||||
"wrank_4_gold": "<:wrank_4_gold:1511906534790266883>",
|
||||
"wrank_5": "<:wrank_5:1511906539223388322>",
|
||||
"wrank_5_gold": "<:wrank_5_gold:1511906543342452837>",
|
||||
"wrank_down": "<:wrank_down:1511906547104616643>",
|
||||
"wrank_down_1": "<:wrank_down_1:1511906550698999909>",
|
||||
"wrank_down_2": "<:wrank_down_2:1511906554507694120>",
|
||||
"wrank_down_3": "<:wrank_down_3:1511906558231969792>",
|
||||
"wrank_down_4": "<:wrank_down_4:1511906562011304007>",
|
||||
"wrank_down_5": "<:wrank_down_5:1511906565630984273>",
|
||||
"wrank_up": "<:wrank_up:1511906568877117576>",
|
||||
"wrank_up_1": "<:wrank_up_1:1511906573537120287>",
|
||||
"wrank_up_2": "<:wrank_up_2:1511906577970364536>",
|
||||
"wrank_up_3": "<:wrank_up_3:1511906581711945909>",
|
||||
"wrank_up_4": "<:wrank_up_4:1511906585503338616>",
|
||||
"wrank_up_5": "<:wrank_up_5:1511906588921954325>"
|
||||
}
|
||||
>>>>>>> dev
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "ts-node src/index.ts"
|
||||
"exec": "ts-node -r tsconfig-paths/register src/index.ts"
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@
|
|||
"description": "Cabal Online TG planning and tracking bot",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "ts-node src/index.ts",
|
||||
"start": "ts-node -r tsconfig-paths/register src/index.ts",
|
||||
"dev": "nodemon",
|
||||
"register": "ts-node src/index.ts --register"
|
||||
"register": "ts-node -r tsconfig-paths/register src/index.ts --register",
|
||||
"aliases": "ts-node scripts/generate-aliases.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14.15.3",
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
"@types/node-cron": "^3.0.0",
|
||||
"nodemon": "^3.1.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0"
|
||||
"typescript": "^5.4.0",
|
||||
"tsconfig-paths": "^4.2.0"
|
||||
}
|
||||
}
|
||||
76
scripts/generate-aliases.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Generates path aliases in tsconfig.json for every directory under src/
|
||||
* and optionally data/ and messages/.
|
||||
*
|
||||
* Usage: npx ts-node scripts/generate-aliases.ts
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const TSCONFIG_PATH = path.join(__dirname, "../tsconfig.json");
|
||||
const SRC_DIR = path.join(__dirname, "../src");
|
||||
|
||||
function getDirs(dir: string, prefix: string): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
if (!fs.existsSync(dir)) return result;
|
||||
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const relPath = path.join(prefix, entry.name);
|
||||
const aliasKey = `@${entry.name}/*`;
|
||||
const aliasVal = [`src/${relPath}/*`];
|
||||
result[aliasKey] = aliasVal;
|
||||
|
||||
// Also add a direct alias for files in this dir (e.g. @utils → src/utils)
|
||||
const directKey = `@${entry.name}`;
|
||||
result[directKey] = [`src/${relPath}`];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Also alias every .ts file directly under src/ (e.g. @utils → src/utils)
|
||||
function getFiles(dir: string): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {};
|
||||
if (!fs.existsSync(dir)) return result;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
|
||||
const name = path.basename(entry.name, ".ts");
|
||||
result[`@${name}`] = [`src/${name}`];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const tsconfig = JSON.parse(fs.readFileSync(TSCONFIG_PATH, "utf8"));
|
||||
|
||||
// Generate aliases for all dirs under src/
|
||||
const newPaths: Record<string, string[]> = {
|
||||
"@src/*": ["src/*"],
|
||||
...getDirs(SRC_DIR, ""),
|
||||
...getFiles(SRC_DIR),
|
||||
};
|
||||
|
||||
// Also scan subdirs (systems, subcommands, handlers, commands)
|
||||
for (const subdir of ["systems", "subcommands", "handlers", "commands"]) {
|
||||
const full = path.join(SRC_DIR, subdir);
|
||||
if (!fs.existsSync(full)) continue;
|
||||
for (const entry of fs.readdirSync(full, { withFileTypes: true })) {
|
||||
const name = entry.isDirectory() ? entry.name : path.basename(entry.name, ".ts");
|
||||
if (!entry.isDirectory() && !entry.name.endsWith(".ts")) continue;
|
||||
// Don't override parent alias
|
||||
if (!newPaths[`@${name}`]) {
|
||||
newPaths[`@${name}`] = entry.isDirectory()
|
||||
? [`src/${subdir}/${name}/*`]
|
||||
: [`src/${subdir}/${name}`];
|
||||
}
|
||||
if (entry.isDirectory() && !newPaths[`@${name}/*`]) {
|
||||
newPaths[`@${name}/*`] = [`src/${subdir}/${name}/*`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tsconfig.compilerOptions.paths = newPaths;
|
||||
fs.writeFileSync(TSCONFIG_PATH, JSON.stringify(tsconfig, null, 2));
|
||||
|
||||
console.log(`✅ Generated ${Object.keys(newPaths).length} path aliases in tsconfig.json`);
|
||||
console.log(Object.keys(newPaths).sort().join("\n"));
|
||||
211
scripts/upload-emojis.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Bulk emoji upload script
|
||||
* Usage: npx ts-node -r tsconfig-paths/register scripts/upload-emojis.ts [emoji_dir]
|
||||
*
|
||||
* Distributes emojis across a pool of donor servers (round-robin by available capacity).
|
||||
* Each emoji is unique across all servers — no duplicates.
|
||||
* Automatically updates messages/emojis.json with the uploaded emoji IDs.
|
||||
*
|
||||
* Required .env vars:
|
||||
* DISCORD_TOKEN — bot token
|
||||
* EMOJI_DONOR_GUILDS — comma-separated donor server IDs
|
||||
* e.g. EMOJI_DONOR_GUILDS=111111111111,222222222222,333333333333
|
||||
*/
|
||||
|
||||
import { REST, Routes } from "discord.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// Load .env manually since we're outside the bot runtime
|
||||
const envPath = path.join(__dirname, "../.env");
|
||||
if (fs.existsSync(envPath)) {
|
||||
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
|
||||
const [key, ...rest] = line.split("=");
|
||||
if (key?.trim() && rest.length) process.env[key.trim()] = rest.join("=").trim();
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||
const DONOR_GUILD_IDS: string[] = (process.env.EMOJI_DONOR_GUILDS ?? "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error("❌ DISCORD_TOKEN must be set in .env");
|
||||
process.exit(1);
|
||||
}
|
||||
if (DONOR_GUILD_IDS.length === 0) {
|
||||
console.error("❌ EMOJI_DONOR_GUILDS must be set in .env (comma-separated guild IDs)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads");
|
||||
const emojisPath = path.join(__dirname, "../messages/emojis.json");
|
||||
|
||||
if (!fs.existsSync(emojiDir)) {
|
||||
console.error(`❌ Emoji directory not found: ${emojiDir}`);
|
||||
console.error(` Create it and place your emoji PNG files inside.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
||||
|
||||
interface GuildEmojiSlot {
|
||||
guildId: string;
|
||||
name: string; // guild name for display
|
||||
existing: Map<string, string>; // emojiName → emojiId
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
// Compute max emojis based on Nitro boost tier
|
||||
function maxEmojisForTier(premiumTier: number): number {
|
||||
switch (premiumTier) {
|
||||
case 1: return 100;
|
||||
case 2: return 150;
|
||||
case 3: return 250;
|
||||
default: return 50;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGuildSlots(guildIds: string[]): Promise<GuildEmojiSlot[]> {
|
||||
const slots: GuildEmojiSlot[] = [];
|
||||
|
||||
for (const guildId of guildIds) {
|
||||
try {
|
||||
const [guild, emojis] = await Promise.all([
|
||||
rest.get(Routes.guild(guildId)) as Promise<any>,
|
||||
rest.get(Routes.guildEmojis(guildId)) as Promise<any[]>,
|
||||
]);
|
||||
|
||||
const maxEmojis = maxEmojisForTier(guild.premium_tier ?? 0);
|
||||
const existingMap = new Map(emojis.map((e: any) => [e.name, e.id]));
|
||||
const capacity = maxEmojis - emojis.length;
|
||||
const guildName = guild.name ?? guildId;
|
||||
|
||||
console.log(`🏠 ${guildName} (${guildId}): ${emojis.length}/${maxEmojis} emojis, ${capacity} slots free`);
|
||||
slots.push({ guildId, name: guildName, existing: existingMap, capacity });
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Could not fetch guild ${guildId}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
async function uploadEmojis(): Promise<void> {
|
||||
const files = fs.readdirSync(emojiDir).filter((f) =>
|
||||
[".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase())
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.error("❌ No image files found in the emoji directory.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load existing emojis.json
|
||||
let emojiMap: Record<string, string> = {};
|
||||
try {
|
||||
emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8"));
|
||||
} catch {
|
||||
console.warn("⚠️ Could not load emojis.json — will create fresh mapping.");
|
||||
}
|
||||
|
||||
console.log(`\n📁 Found ${files.length} file(s) in ${emojiDir}`);
|
||||
console.log(`🔍 Scanning ${DONOR_GUILD_IDS.length} donor server(s)...\n`);
|
||||
|
||||
const guildSlots = await fetchGuildSlots(DONOR_GUILD_IDS);
|
||||
|
||||
if (guildSlots.length === 0) {
|
||||
console.error("❌ No accessible donor guilds found. Check EMOJI_DONOR_GUILDS and bot membership.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build global deduplication map across all donor guilds
|
||||
const globalExisting = new Map<string, string>(); // emojiName → formatted string
|
||||
for (const slot of guildSlots) {
|
||||
for (const [name, id] of slot.existing) {
|
||||
globalExisting.set(name, `<:${name}:${id}>`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCapacity = guildSlots.reduce((sum, s) => sum + s.capacity, 0);
|
||||
console.log(`\n📊 ${globalExisting.size} emoji(s) already exist · ${totalCapacity} slots available across all servers\n`);
|
||||
|
||||
if (totalCapacity === 0) {
|
||||
console.error("❌ All donor servers are full! Add more servers to EMOJI_DONOR_GUILDS.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Round-robin slot picker — distributes load evenly across guilds
|
||||
let slotIndex = 0;
|
||||
function nextAvailableSlot(): GuildEmojiSlot | null {
|
||||
const start = slotIndex;
|
||||
do {
|
||||
const slot = guildSlots[slotIndex % guildSlots.length];
|
||||
slotIndex++;
|
||||
if (slot.capacity > 0) return slot;
|
||||
} while (slotIndex % guildSlots.length !== start % guildSlots.length);
|
||||
// Fallback: find any with capacity (in case loop exited without finding one)
|
||||
return guildSlots.find((s) => s.capacity > 0) ?? null;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const emojiName = path.basename(file, path.extname(file));
|
||||
const filePath = path.join(emojiDir, file);
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png";
|
||||
|
||||
// Already exists in the pool — update map and skip
|
||||
if (globalExisting.has(emojiName)) {
|
||||
emojiMap[emojiName] = globalExisting.get(emojiName)!;
|
||||
console.log(`⏭️ Already exists: ${emojiName} → ${emojiMap[emojiName]}`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const slot = nextAvailableSlot();
|
||||
if (!slot) {
|
||||
console.error(`❌ All slots full — could not upload: ${emojiName}`);
|
||||
console.error(` Add more servers to EMOJI_DONOR_GUILDS in .env`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`;
|
||||
const result = await rest.post(Routes.guildEmojis(slot.guildId), {
|
||||
body: { name: emojiName, image: base64 },
|
||||
}) as any;
|
||||
|
||||
const formatted = `<:${emojiName}:${result.id}>`;
|
||||
emojiMap[emojiName] = formatted;
|
||||
slot.capacity--;
|
||||
|
||||
console.log(`✅ Uploaded: ${emojiName} → ${formatted} [${slot.name}]`);
|
||||
uploaded++;
|
||||
|
||||
// Rate limit buffer
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Failed: ${emojiName} — ${err.message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2));
|
||||
|
||||
console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`);
|
||||
console.log(`💾 messages/emojis.json updated`);
|
||||
console.log(`\nSlot usage after upload:`);
|
||||
for (const slot of guildSlots) {
|
||||
const used = slot.existing.size + (uploaded > 0 ? slot.existing.size : 0);
|
||||
console.log(` ${slot.name}: ${slot.capacity} slots remaining`);
|
||||
}
|
||||
}
|
||||
|
||||
uploadEmojis().catch(console.error);
|
||||
|
|
@ -18,6 +18,7 @@ import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEp
|
|||
import { handleInject, handleRemoveVote } from "../subcommands/poll/inject";
|
||||
import { handleSeed } from "../subcommands/poll/seed";
|
||||
import { handlePurge } from "../subcommands/poll/purge";
|
||||
import { handleImpersonate } from "../subcommands/impersonate";
|
||||
|
||||
// Char subcommands (borrow / sharing system)
|
||||
import { handleCharBorrow } from "../subcommands/char/borrow";
|
||||
|
|
@ -46,6 +47,14 @@ import { handleBringerClear } from "../subcommands/bringer/clear";
|
|||
import { handleSwitch } from "../subcommands/switch";
|
||||
import { handleHistory } from "../subcommands/history";
|
||||
|
||||
// Import char handlers here to keep tg.ts clean
|
||||
import { handleCharAdd } from "../subcommands/char/add";
|
||||
import { handleCharRemove } from "../subcommands/char/remove";
|
||||
import { handleCharSetActive } from "../subcommands/char/setActive";
|
||||
import { handleCharSetNation } from "../subcommands/char/setNation";
|
||||
import { handleCharSetStats } from "../subcommands/char/setStats";
|
||||
import { handleCharActive } from "../subcommands/char/active";
|
||||
|
||||
export function buildTgCommand(): SlashCommandBuilder {
|
||||
const cmd = new SlashCommandBuilder()
|
||||
.setName("tg")
|
||||
|
|
@ -56,41 +65,65 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.setName("poll")
|
||||
.setDescription("Manage the TG poll")
|
||||
.addSubcommand((s) => s.setName("start").setDescription("Post a fresh TG poll")
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll")
|
||||
.addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false))
|
||||
.addBooleanOption((o) => o.setName("simulate_close").setDescription("Simulate poll lock").setRequired(false))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("unlock").setDescription("Unlock the active poll"))
|
||||
.addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening")
|
||||
.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"))
|
||||
.addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").setRequired(false))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk")
|
||||
.addStringOption(opt =>
|
||||
opt.setName("target")
|
||||
.setDescription("What to reload")
|
||||
.setRequired(false)
|
||||
.addChoices(
|
||||
{ name: "All", value: "all" },
|
||||
{ name: "Config", value: "config" },
|
||||
{ name: "Messages", value: "messages" },
|
||||
{ name: "Emojis", value: "emojis" },
|
||||
{ name: "Characters", value: "characters" },
|
||||
{ name: "W.Rank", value: "wrank" },
|
||||
{ name: "Poll", value: "poll" },
|
||||
)
|
||||
)
|
||||
)
|
||||
.addSubcommand((s) => s.setName("status").setDescription("Show current poll and config status"))
|
||||
.addSubcommand((s) => s.setName("set-message").setDescription("Set public message override for a user")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override")
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true)
|
||||
.addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })))
|
||||
.addSubcommand((s) => s.setName("remove-vote").setDescription("Remove a vote for a registered user")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel"))
|
||||
.addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing"))
|
||||
);
|
||||
|
|
@ -102,10 +135,17 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.addSubcommand((s) => s.setName("set").setDescription("Submit a score")
|
||||
.addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true))
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addIntegerOption((o) => o.setName("k").setDescription("Kills").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("d").setDescription("Deaths").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("heal").setDescription("Healing score (FA only)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("get").setDescription("View a score")
|
||||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
);
|
||||
|
||||
// ── rank group ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -113,7 +153,8 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.setName("rank")
|
||||
.setDescription("W.Rank management")
|
||||
.addSubcommand((s) => s.setName("get").setDescription("View W.Rank")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)"))
|
||||
);
|
||||
|
||||
|
|
@ -140,7 +181,8 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })))
|
||||
|
|
@ -148,8 +190,8 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
|
||||
// ── switch ─────────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
);
|
||||
|
||||
// ── char group ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -173,40 +215,53 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true))
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("remove").setDescription("Remove a character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("set-active").setDescription("Set active character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation")
|
||||
.addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true)
|
||||
.addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false))
|
||||
.addIntegerOption((o) => o.setName("heal").setDescription("Healing score").setRequired(false))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session")
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true)))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("share").setDescription("Permanently share a character")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share")
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false)))
|
||||
.addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true).setAutocomplete(true))
|
||||
.addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
.addSubcommand((s) => s.setName("active").setDescription("Check active character for a user")
|
||||
.addStringOption((o) => o.setName("name").setDescription("Usermap key (officer: check others)").setRequired(false).setAutocomplete(true))
|
||||
)
|
||||
);
|
||||
|
||||
// ── history ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -215,6 +270,9 @@ export function buildTgCommand(): SlashCommandBuilder {
|
|||
.addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false))
|
||||
);
|
||||
|
||||
// ── impersonate ────────────────────────────────────────────────────────────────
|
||||
cmd.addSubcommand((s) => s.setName("impersonate").setDescription("Impersonate a registered user for testing (officer only)"));
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
|
|
@ -284,14 +342,9 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction):
|
|||
if (sub === "decline") return handleCharDecline(interaction);
|
||||
if (sub === "share") return handleCharShare(interaction);
|
||||
if (sub === "unshare") return handleCharUnshare(interaction);
|
||||
if (sub === "active") return handleCharActive(interaction);
|
||||
}
|
||||
if (!group && sub === "switch") return handleSwitch(interaction);
|
||||
if (!group && sub === "history") return handleHistory(interaction);
|
||||
if (!group && sub === "switch") return handleSwitch(interaction);
|
||||
if (!group && sub === "history") return handleHistory(interaction);
|
||||
if (!group && sub === "impersonate") return handleImpersonate(interaction);
|
||||
}
|
||||
|
||||
// Import char handlers here to keep tg.ts clean
|
||||
import { handleCharAdd } from "../subcommands/char/add";
|
||||
import { handleCharRemove } from "../subcommands/char/remove";
|
||||
import { handleCharSetActive } from "../subcommands/char/setActive";
|
||||
import { handleCharSetNation } from "../subcommands/char/setNation";
|
||||
import { handleCharSetStats } from "../subcommands/char/setStats";
|
||||
104
src/handlers/autocomplete.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { AutocompleteInteraction } from "discord.js";
|
||||
import { resolveUser } from "@systems/users";
|
||||
import { getCharacters } from "@systems/characters";
|
||||
import { cfg } from "@systems/config";
|
||||
import { getNationEmoji } from "@systems/emojis";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// ─── Autocomplete subsets ─────────────────────────────────────────────────────
|
||||
|
||||
async function autocompleteCharNames(
|
||||
interaction: AutocompleteInteraction,
|
||||
focused: string
|
||||
): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const user = await resolveUser(member);
|
||||
if (!user.userKey) return interaction.respond([]);
|
||||
|
||||
const ownChars = getCharacters(user.userKey).map((c) => {
|
||||
const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : "";
|
||||
return {
|
||||
name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(),
|
||||
value: c.name,
|
||||
};
|
||||
});
|
||||
|
||||
const sharedChars: { name: string; value: string }[] = [];
|
||||
try {
|
||||
const chars = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
|
||||
);
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
if (ownerKey === user.userKey) continue;
|
||||
for (const char of data.characters ?? []) {
|
||||
if (char.sharedWith?.includes(user.userKey)) {
|
||||
const nationEmoji = char.nation ? (getNationEmoji(char.nation) || char.nation) : "";
|
||||
sharedChars.push({
|
||||
name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(),
|
||||
value: char.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const all = [...ownChars, ...sharedChars]
|
||||
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
|
||||
.slice(0, 25);
|
||||
|
||||
await interaction.respond(all);
|
||||
}
|
||||
|
||||
async function autocompleteUserKeys(
|
||||
interaction: AutocompleteInteraction,
|
||||
focused: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const usermap = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
|
||||
);
|
||||
const choices = Object.entries(usermap)
|
||||
.map(([, entry]: [string, any]) => {
|
||||
const fileKey = typeof entry === "string" ? entry : entry.file;
|
||||
const alias = typeof entry === "object" ? (entry.aliases?.[0] ?? fileKey) : fileKey;
|
||||
return { name: `${alias} (${fileKey})`, value: fileKey };
|
||||
})
|
||||
.filter((c) => c.name.toLowerCase().includes(focused.toLowerCase()))
|
||||
.slice(0, 25);
|
||||
await interaction.respond(choices);
|
||||
} catch {
|
||||
await interaction.respond([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function autocompleteSlots(
|
||||
interaction: AutocompleteInteraction,
|
||||
focused: string
|
||||
): Promise<void> {
|
||||
const slots = cfg("slots")
|
||||
.filter((s) => s.active)
|
||||
.map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) }))
|
||||
.filter((s) => s.name.includes(focused));
|
||||
await interaction.respond(slots);
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleAutocomplete(interaction: AutocompleteInteraction): Promise<void> {
|
||||
try {
|
||||
const focused = interaction.options.getFocused(true);
|
||||
const optionName = focused.name;
|
||||
const focusedValue = focused.value as string;
|
||||
|
||||
if (optionName === "char_name") return await autocompleteCharNames(interaction, focusedValue);
|
||||
if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue);
|
||||
if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue);
|
||||
if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue);
|
||||
|
||||
await interaction.respond([]);
|
||||
} catch (err) {
|
||||
console.error("[autocomplete] error:", err);
|
||||
try { await interaction.respond([]); } catch {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,105 @@
|
|||
import { ButtonInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../systems/config";
|
||||
import { resolveUser } from "../systems/users";
|
||||
import { resolveMessage, nowFormatted } from "../systems/messages";
|
||||
import { resolveNation } from "../systems/nations";
|
||||
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "../systems/poll";
|
||||
import {
|
||||
ButtonInteraction,
|
||||
StringSelectMenuBuilder,
|
||||
StringSelectMenuOptionBuilder,
|
||||
ActionRowBuilder,
|
||||
TextChannel
|
||||
} from "discord.js";
|
||||
import { cfg } from "@systems/config";
|
||||
import { pollReplyAndDelete } from "../utils";
|
||||
import { resolveUser } from "@systems/users";
|
||||
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||
import { resolveNation } from "@systems/nations";
|
||||
import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll";
|
||||
import { persist } from "@systems/pollPersistence"
|
||||
import { showConflictEmbed } from "@systems/conflict";
|
||||
import { getCharacters } from "@systems/characters";
|
||||
import { getImpersonation } from "@systems/impersonate";
|
||||
import { format } from "@format";
|
||||
import { Character } from "@src/types";
|
||||
import { modals } from "@handlers/modals";
|
||||
|
||||
const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false";
|
||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
||||
const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10");
|
||||
|
||||
const clickCounts = new Map<string, { yes: number; no: number }>();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function isCharacterInPoll(
|
||||
state: ReturnType<typeof polls.get>,
|
||||
charName: string,
|
||||
excludeVoteId: string,
|
||||
excludeUserKey: string = ""
|
||||
): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } {
|
||||
if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined };
|
||||
for (const [otherId, entry] of state.yes.entries()) {
|
||||
if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) {
|
||||
return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom };
|
||||
}
|
||||
}
|
||||
return { found: false, entryUserKey: null, borrowedFrom: undefined };
|
||||
}
|
||||
|
||||
function isCharacterOwner(userKey: string | null, charName: string): boolean {
|
||||
if (!userKey) return false;
|
||||
return getCharacters(userKey).some((c) => c.name === charName);
|
||||
}
|
||||
|
||||
async function handleCharacterConflict(
|
||||
interaction: ButtonInteraction,
|
||||
userKey: string | null,
|
||||
char: Character,
|
||||
entryUserKey: string | null,
|
||||
clicks: { yes: number; no: number },
|
||||
votedYes: boolean
|
||||
): Promise<boolean> {
|
||||
// Decrement click since we're blocking this vote
|
||||
if (votedYes) clicks.yes -= 1;
|
||||
else clicks.no -= 1;
|
||||
|
||||
const isOwner = isCharacterOwner(userKey, char.name);
|
||||
|
||||
if (isOwner && userKey) {
|
||||
const allChars = getCharacters(userKey);
|
||||
const borrowedChar = allChars.find((c) => c.name === char.name);
|
||||
if (borrowedChar && entryUserKey) {
|
||||
await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const slot = [...polls.keys()][0];
|
||||
const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20;
|
||||
|
||||
// await interaction.followUp({
|
||||
// content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
||||
// // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`,
|
||||
// ephemeral: true
|
||||
// });
|
||||
const { buildCharSelectButtons } = require("@systems/charSelect");
|
||||
const buttons = buildCharSelectButtons(userKey ?? "", {
|
||||
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||
excludeCharName: char.name,
|
||||
appendToCustomId: ":yes",
|
||||
});
|
||||
await interaction.followUp({
|
||||
content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
||||
components: buttons,
|
||||
ephemeral: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Main button handler ──────────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
try {
|
||||
await interaction.deferUpdate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
|
||||
if (slot === undefined) return;
|
||||
|
|
@ -24,61 +108,91 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
|||
if (state.locked || state.confirmed !== null) return;
|
||||
|
||||
const userId = interaction.user.id;
|
||||
const member = await interaction.guild!.members.fetch(userId);
|
||||
const member = interaction.guild!.members.cache.get(userId)
|
||||
?? 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);
|
||||
const impersonating = getImpersonation(userId);
|
||||
const voteId = impersonating ? `impersonated:${impersonating}` : userId;
|
||||
const lookupUsername = user.lookupUsername ?? user.discordUsername;
|
||||
|
||||
// Nation check
|
||||
const nation = resolveNation(member, user.userKey);
|
||||
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);
|
||||
}
|
||||
const capella = format.nation("Capella");
|
||||
const procyon = format.nation("Procyon");
|
||||
await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Click tracking
|
||||
if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 });
|
||||
const clicks = clickCounts.get(userId)!;
|
||||
if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 });
|
||||
const clicks = clickCounts.get(voteId)!;
|
||||
|
||||
if (votedYes && clicks.yes >= LOCK_AT) return;
|
||||
if (!votedYes && clicks.no >= LOCK_AT) return;
|
||||
|
||||
// Ignore same vote
|
||||
if (votedYes && state.yes.has(userId)) return;
|
||||
if (!votedYes && state.no.has(userId)) return;
|
||||
if (votedYes && state.yes.has(voteId)) return;
|
||||
if (!votedYes && state.no.has(voteId)) return;
|
||||
|
||||
// Increment click (may be decremented in conflict handler)
|
||||
if (votedYes) clicks.yes += 1;
|
||||
else clicks.no += 1;
|
||||
|
||||
const clickCount = votedYes ? clicks.yes : clicks.no;
|
||||
|
||||
// Resolve messages — officer override takes priority
|
||||
const publicMsg = getPublicOverride(userId, votedYes ? "yes" : "no")
|
||||
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
|
||||
// Resolve messages
|
||||
const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no")
|
||||
?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
|
||||
|
||||
const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no")
|
||||
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname);
|
||||
const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no")
|
||||
?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname);
|
||||
|
||||
const baseEntry = createVoteEntry(userId, member, user.usermapKey, user.discordUsername);
|
||||
const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername);
|
||||
|
||||
// Character conflict check — applies to both Yes and No
|
||||
if (baseEntry.characterName) {
|
||||
const conflictChar = {
|
||||
name: baseEntry.characterName!,
|
||||
class: baseEntry.characterClass!,
|
||||
level: baseEntry.characterLevel!,
|
||||
nation: baseEntry.characterNation!,
|
||||
active: false, // not needed for display
|
||||
};
|
||||
|
||||
const { found, entryUserKey, borrowedFrom } = isCharacterInPoll(
|
||||
state, baseEntry.characterName, voteId, user.userKey ?? ""
|
||||
);
|
||||
if (found) {
|
||||
await handleCharacterConflict(
|
||||
interaction, user.userKey, conflictChar,
|
||||
entryUserKey, clicks, votedYes
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Register vote
|
||||
if (votedYes) {
|
||||
const previousNo = state.no.get(userId);
|
||||
state.no.delete(userId);
|
||||
state.yes.set(userId, {
|
||||
const previousNo = state.no.get(voteId);
|
||||
state.no.delete(voteId);
|
||||
state.yes.set(voteId, {
|
||||
...baseEntry,
|
||||
discordId: userId,
|
||||
votedAt: now,
|
||||
previousNoAt: previousNo?.votedAt,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
});
|
||||
} else {
|
||||
const previousYes = state.yes.get(userId);
|
||||
state.yes.delete(userId);
|
||||
state.no.set(userId, {
|
||||
const previousYes = state.yes.get(voteId);
|
||||
state.yes.delete(voteId);
|
||||
state.no.set(voteId, {
|
||||
...baseEntry,
|
||||
votedAt: now,
|
||||
discordId: userId,
|
||||
previousYesAt: previousYes?.votedAt,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
});
|
||||
|
|
@ -86,19 +200,13 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
|||
|
||||
const locked = clickCount >= LOCK_AT;
|
||||
if (locked) state.locked = true;
|
||||
persist.save(polls);
|
||||
|
||||
// Send ephemeral follow-up (since we already deferred with deferUpdate)
|
||||
if (EPHEMERAL_ENABLED) {
|
||||
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
||||
const content = ephemeralMsg
|
||||
? `${ephemeralMsg}${lockedSuffix}`
|
||||
: locked ? "🔒 You've been locked in." : null;
|
||||
|
||||
if (content) {
|
||||
const reply = await interaction.followUp({ content, ephemeral: true });
|
||||
if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||
}
|
||||
}
|
||||
const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : "";
|
||||
const msgContent = ephemeralMsg
|
||||
? `${ephemeralMsg}${lockedSuffix}`
|
||||
: locked ? "🔒 You've been locked in." : null;
|
||||
await pollReplyAndDelete(interaction, msgContent);
|
||||
|
||||
const channel = interaction.channel as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
|
|
@ -107,3 +215,70 @@ export async function handleButton(interaction: ButtonInteraction): Promise<void
|
|||
export function resetClickCounts(): void {
|
||||
clickCounts.clear();
|
||||
}
|
||||
|
||||
|
||||
// ─── Score submission button handler ──────────────────────────────────────────────────────
|
||||
|
||||
export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const user = await resolveUser(member);
|
||||
if (!user.userKey) {
|
||||
await interaction.reply({ content: "❌ You are not registered in the system.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
|
||||
const state = slot !== undefined ? polls.get(slot) : null;
|
||||
|
||||
if (!state?.lockedYesKeys?.has(user.userKey)) {
|
||||
await interaction.reply({ content: "❌ You weren't in this TG.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Slot is known from the poll — go straight to modal, no select needed
|
||||
await interaction.showModal(modals.buildScoreModal(user.userKey, slot!));
|
||||
}
|
||||
|
||||
// export async function handleScoreSubmitButton(interaction: ButtonInteraction): Promise<void> {
|
||||
// await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
// const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
// const user = await resolveUser(member);
|
||||
// if (!user.userKey) {
|
||||
// await interaction.editReply("❌ You are not registered in the system.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Find the poll this message belongs to
|
||||
// const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0];
|
||||
// const state = slot !== undefined ? polls.get(slot) : null;
|
||||
|
||||
// // Enforce: only players who were locked in at TG start can submit
|
||||
// if (!state?.lockedYesKeys?.has(user.userKey)) {
|
||||
// await interaction.editReply("❌ You weren't in this TG.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // Build slot selector — all valid slots, with the active TG pre-selected
|
||||
// const validSlots = cfg("slots").map((s) => s.tgHour) as number[];
|
||||
// const activeSlot = slot ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
|
||||
|
||||
// const select = new StringSelectMenuBuilder()
|
||||
// .setCustomId(`score_slot_select:${user.userKey}`)
|
||||
// .setPlaceholder("Select TG slot")
|
||||
// .addOptions(
|
||||
// validSlots.map((h) =>
|
||||
// new StringSelectMenuOptionBuilder()
|
||||
// .setLabel(`${String(h).padStart(2, "0")}:00 TG`)
|
||||
// .setValue(String(h))
|
||||
// .setDefault(h === activeSlot)
|
||||
// )
|
||||
// );
|
||||
|
||||
// const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select);
|
||||
|
||||
// await interaction.editReply({
|
||||
// content: "Which TG are you submitting for?",
|
||||
// components: [row],
|
||||
// });
|
||||
// }
|
||||
|
|
@ -1,28 +1,171 @@
|
|||
import { Interaction, ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||
import { handleButton } from "./buttons";
|
||||
import { handleTgCommand } from "../commands/tg";
|
||||
import { handleTgConfigCommand } from "../commands/tgConfig";
|
||||
import { handleBorrowAcceptButton } from "../subcommands/char/accept";
|
||||
import { handleBorrowDeclineButton } from "../subcommands/char/decline";
|
||||
import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel, StringSelectMenuInteraction } from "discord.js";
|
||||
import { handleButton, handleScoreSubmitButton } from "@handlers/buttons";
|
||||
import { handleTgCommand } from "@commands/tg";
|
||||
import { handleTgConfigCommand } from "@commands/tgConfig";
|
||||
import { handleBorrowAcceptButton } from "@subcommands/char/accept";
|
||||
import { handleBorrowDeclineButton } from "@subcommands/char/decline";
|
||||
import { handleConflictButton } from "@systems/conflict";
|
||||
import { handleImpersonateButton } from "@subcommands/impersonate";
|
||||
import { handleAutocomplete } from "@handlers/autocomplete";
|
||||
import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
|
||||
import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { cfg } from "@systems/config";
|
||||
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||
import { format } from "@format";
|
||||
import { modals } from "@handlers/modals";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise<void> {
|
||||
const parts = btn.customId.split(":");
|
||||
const userKey = parts[1];
|
||||
const charName = parts[2];
|
||||
const prevVoteType = (parts[3] ?? "yes") as "yes" | "no";
|
||||
|
||||
const chars = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8")
|
||||
);
|
||||
|
||||
let resolvedChar: any = null;
|
||||
let borrowedFrom: string | null = null;
|
||||
|
||||
// Try own char first
|
||||
const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName);
|
||||
if (ownEntry) {
|
||||
setActiveCharacter(userKey, charName);
|
||||
clearSessionBorrowForUser(userKey);
|
||||
resolvedChar = ownEntry;
|
||||
} else {
|
||||
// Try shared char
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
const char = data.characters?.find(
|
||||
(c: any) => c.name === charName && c.sharedWith?.includes(userKey)
|
||||
);
|
||||
if (char) {
|
||||
setPersistentPreference(userKey, ownerKey, charName);
|
||||
clearSessionBorrowForUser(userKey);
|
||||
resolvedChar = char;
|
||||
borrowedFrom = ownerKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedChar) {
|
||||
await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-add to poll with previous vote type
|
||||
const slot = [...polls.keys()][0];
|
||||
const state = slot !== undefined ? polls.get(slot) : null;
|
||||
|
||||
if (state && !state.locked && state.confirmed === null) {
|
||||
const { char } = getEffectiveCharacter(userKey);
|
||||
const now = nowFormatted();
|
||||
const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null);
|
||||
const voteEntry = {
|
||||
userKey,
|
||||
displayName: charName,
|
||||
characterName: char?.name ?? charName,
|
||||
characterClass: char?.class ?? resolvedChar.class,
|
||||
characterLevel: char?.level ?? resolvedChar.level,
|
||||
characterNation: char?.nation ?? resolvedChar.nation,
|
||||
borrowedFrom: borrowedFrom ?? undefined,
|
||||
discordId: btn.user.id,
|
||||
votedAt: now,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
};
|
||||
|
||||
// Find and reuse existing vote ID — avoids duplicate entries
|
||||
let existingVoteId: string | null = null;
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.userKey === userKey) {
|
||||
if (!existingVoteId) existingVoteId = id;
|
||||
state.yes.delete(id);
|
||||
state.no.delete(id);
|
||||
}
|
||||
}
|
||||
const voteId = existingVoteId ?? btn.user.id;
|
||||
|
||||
if (prevVoteType === "yes") {
|
||||
state.yes.set(voteId, voteEntry);
|
||||
} else {
|
||||
state.no.set(voteId, voteEntry);
|
||||
}
|
||||
|
||||
console.log(`[switch_reclaim] cleaning up for userKey=${userKey}`);
|
||||
console.log(`[switch_reclaim] yes keys:`, [...state.yes.entries()].map(([id, e]) => `${id}:${e.userKey}`));
|
||||
console.log(`[switch_reclaim] no keys:`, [...state.no.entries()].map(([id, e]) => `${id}:${e.userKey}`));
|
||||
|
||||
const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot!);
|
||||
}
|
||||
|
||||
const charDisplay = resolvedChar ? format.char(resolvedChar) : charName;
|
||||
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||
await btn.reply({
|
||||
content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`,
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleInteraction(interaction: Interaction): Promise<void> {
|
||||
try {
|
||||
if (interaction.isAutocomplete()) {
|
||||
await handleAutocomplete(interaction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interaction.isButton()) {
|
||||
const btn = interaction as ButtonInteraction;
|
||||
|
||||
// Borrow accept/decline buttons from DM
|
||||
console.log("[interactions] interaction btnId:", btn.customId);
|
||||
if (btn.customId.startsWith("conflict_")) {
|
||||
console.log("[interactions] routing to conflict handler:", btn.customId);
|
||||
return await handleConflictButton(btn);
|
||||
}
|
||||
|
||||
if (btn.customId.startsWith("impersonate_")) {
|
||||
return await handleImpersonateButton(btn);
|
||||
}
|
||||
|
||||
if (btn.customId.startsWith("switch_after_reclaim:")) {
|
||||
return await handleSwitchAfterReclaim(btn);
|
||||
}
|
||||
|
||||
if (btn.customId.startsWith("borrow_accept:")) {
|
||||
const [, ownerKey, requesterKey] = btn.customId.split(":");
|
||||
return await handleBorrowAcceptButton(btn, ownerKey, requesterKey);
|
||||
}
|
||||
|
||||
if (btn.customId.startsWith("borrow_decline:")) {
|
||||
const [, ownerKey, requesterKey] = btn.customId.split(":");
|
||||
return await handleBorrowDeclineButton(btn, ownerKey, requesterKey);
|
||||
}
|
||||
|
||||
if (btn.customId === "tg_score_submit") {
|
||||
return await handleScoreSubmitButton(btn);
|
||||
}
|
||||
|
||||
return await handleButton(btn);
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit()) {
|
||||
return await modals.handleModal(interaction);
|
||||
}
|
||||
|
||||
if (interaction.isStringSelectMenu()) {
|
||||
const sel = interaction as StringSelectMenuInteraction;
|
||||
if (sel.customId.startsWith("score_slot_select:")) {
|
||||
const userKey = sel.customId.split(":")[1];
|
||||
const slot = parseInt(sel.values[0], 10);
|
||||
await sel.showModal(modals.buildScoreModal(userKey, slot));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.isChatInputCommand()) {
|
||||
const cmd = interaction as ChatInputCommandInteraction;
|
||||
if (cmd.commandName === "tg") await handleTgCommand(cmd);
|
||||
|
|
|
|||
156
src/handlers/modals.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import {
|
||||
ModalSubmitInteraction,
|
||||
ModalBuilder,
|
||||
TextInputBuilder,
|
||||
TextInputStyle,
|
||||
ActionRowBuilder,
|
||||
} from "discord.js";
|
||||
import { resolveUser } from "@systems/users";
|
||||
import { getEffectiveCharacter } from "@systems/borrow";
|
||||
import { score } from "@subcommands/score/submitCore";
|
||||
import { format } from "@format";
|
||||
|
||||
// ─── Modal IDs ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// score_submit:<slot> — score submission modal, slot baked into the customId
|
||||
// so we don't need to pass state through the modal itself
|
||||
|
||||
export namespace modals {
|
||||
|
||||
// ─── Builder ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the score submission modal for a given userKey + slot.
|
||||
* Title shows the active character so the user knows what they're submitting for.
|
||||
*/
|
||||
export function buildScoreModal(userKey: string, slot: number): ModalBuilder {
|
||||
const { char } = getEffectiveCharacter(userKey);
|
||||
// const charLabel = char ? format.char(char) : "your character";
|
||||
const charLabel = char ? format.char(char, { emoji: false }) : "your character";
|
||||
|
||||
const modal = new ModalBuilder()
|
||||
.setCustomId(`score_submit:${slot}`)
|
||||
.setTitle(`Score for ${charLabel} — ${String(slot).padStart(2, "0")}:00`);
|
||||
|
||||
const ptsInput = new TextInputBuilder()
|
||||
.setCustomId("pts")
|
||||
.setLabel("Points")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("e.g. 3000")
|
||||
.setRequired(true);
|
||||
|
||||
const kdInput = new TextInputBuilder()
|
||||
.setCustomId("kd")
|
||||
.setLabel("Kills / Deaths (e.g. 5/2)")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("5/2")
|
||||
.setRequired(false);
|
||||
|
||||
const atkDefInput = new TextInputBuilder()
|
||||
.setCustomId("atkdef")
|
||||
.setLabel("ATK / DEF (e.g. 120/80)")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("120/80")
|
||||
.setRequired(false);
|
||||
|
||||
const healInput = new TextInputBuilder()
|
||||
.setCustomId("heal")
|
||||
.setLabel("Heal")
|
||||
.setStyle(TextInputStyle.Short)
|
||||
.setPlaceholder("e.g. 500")
|
||||
.setRequired(false);
|
||||
|
||||
modal.addComponents(
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(ptsInput),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(kdInput),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(atkDefInput),
|
||||
new ActionRowBuilder<TextInputBuilder>().addComponents(healInput),
|
||||
);
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
// ─── Parser helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function parseSlashPair(raw: string | null): [number, number] | null {
|
||||
if (!raw) return null;
|
||||
const parts = raw.trim().split("/");
|
||||
if (parts.length !== 2) return null;
|
||||
const a = parseInt(parts[0], 10);
|
||||
const b = parseInt(parts[1], 10);
|
||||
if (isNaN(a) || isNaN(b)) return null;
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
function parseOptionalInt(raw: string | null): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const n = parseInt(raw.trim(), 10);
|
||||
return isNaN(n) ? undefined : n;
|
||||
}
|
||||
|
||||
// ─── Handler ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleModal(interaction: ModalSubmitInteraction): Promise<void> {
|
||||
if (interaction.customId.startsWith("score_submit:")) {
|
||||
await handleScoreSubmit(interaction);
|
||||
return;
|
||||
}
|
||||
// Future modals routed here by customId prefix
|
||||
}
|
||||
|
||||
async function handleScoreSubmit(interaction: ModalSubmitInteraction): Promise<void> {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
|
||||
const slotStr = interaction.customId.split(":")[1];
|
||||
const slot = parseInt(slotStr, 10);
|
||||
if (isNaN(slot)) {
|
||||
await interaction.editReply("❌ Invalid slot in modal.");
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const user = await resolveUser(member);
|
||||
if (!user.userKey) {
|
||||
await interaction.editReply("❌ You are not registered in the system.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse fields
|
||||
const ptsRaw = interaction.fields.getTextInputValue("pts");
|
||||
const kdRaw = interaction.fields.getTextInputValue("kd") || null;
|
||||
const atkDefRaw = interaction.fields.getTextInputValue("atkdef") || null;
|
||||
const healRaw = interaction.fields.getTextInputValue("heal") || null;
|
||||
|
||||
const pts = parseInt(ptsRaw.trim(), 10);
|
||||
if (isNaN(pts)) {
|
||||
await interaction.editReply("❌ Points must be a number.");
|
||||
return;
|
||||
}
|
||||
|
||||
const kd = parseSlashPair(kdRaw);
|
||||
const atkDef = parseSlashPair(atkDefRaw);
|
||||
const heal = parseOptionalInt(healRaw);
|
||||
|
||||
if (kdRaw && !kd) {
|
||||
await interaction.editReply("❌ K/D must be in `kills/deaths` format, e.g. `5/2`.");
|
||||
return;
|
||||
}
|
||||
if (atkDefRaw && !atkDef) {
|
||||
await interaction.editReply("❌ ATK/DEF must be in `atk/def` format, e.g. `120/80`.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await score.submitForUser({
|
||||
userKey: user.userKey,
|
||||
pts,
|
||||
slot,
|
||||
k: kd?.[0],
|
||||
d: kd?.[1],
|
||||
atk: atkDef?.[0],
|
||||
def: atkDef?.[1],
|
||||
heal,
|
||||
});
|
||||
|
||||
await interaction.editReply(result.message);
|
||||
}
|
||||
}
|
||||
75
src/index.ts
|
|
@ -1,15 +1,16 @@
|
|||
import { Client, GatewayIntentBits, REST, Routes } from "discord.js";
|
||||
import { loadConfig, cfg } from "./systems/config";
|
||||
import { loadMessages } from "./systems/messages";
|
||||
import { loadEmojis } from "./systems/emojis";
|
||||
import { loadCharacters } from "./systems/characters";
|
||||
import { loadWRank } from "./systems/wrank";
|
||||
import { scheduleSlots } from "./systems/slots";
|
||||
import { postPoll, polls } from "./systems/poll";
|
||||
import { handleInteraction } from "./handlers/interactions";
|
||||
import { buildTgCommand } from "./commands/tg";
|
||||
import { buildTgConfigCommand } from "./commands/tgConfig";
|
||||
import { TGSlot } from "./types";
|
||||
import { Client, GatewayIntentBits, TextChannel, REST, Routes } from "discord.js";
|
||||
import { loadConfig, cfg } from "@systems/config";
|
||||
import { loadMessages } from "@systems/messages";
|
||||
import { loadEmojis } from "@systems/emojis";
|
||||
import { loadCharacters } from "@systems/characters";
|
||||
import { loadWRank } from "@systems/wrank";
|
||||
import { scheduleSlots } from "@systems/slots";
|
||||
import { postPoll, polls, lockPoll, updatePollMessage } from "@systems/poll";
|
||||
import { handleInteraction } from "@handlers/interactions";
|
||||
import { buildTgCommand } from "@commands/tg";
|
||||
import { buildTgConfigCommand } from "@commands/tgConfig";
|
||||
import { TGSlot } from "@src/types";
|
||||
import { persist } from "@systems/pollPersistence"
|
||||
|
||||
const TOKEN = process.env.DISCORD_TOKEN!;
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
|
|
@ -28,21 +29,35 @@ async function registerCommands(): Promise<void> {
|
|||
}
|
||||
|
||||
async function onPollOpen(slot: TGSlot): Promise<void> {
|
||||
const channelId = cfg("pollChannelId");
|
||||
const channel = await client.channels.fetch(channelId) as any;
|
||||
const channel = await client.channels.fetch(cfg("pollChannelId")) as any;
|
||||
if (!channel) return console.error("Poll channel not found.");
|
||||
await postPoll(channel, slot);
|
||||
}
|
||||
|
||||
// Fires at tgHour exactly (e.g. 20:00) — voting closes, lockedYesKeys snapshotted
|
||||
async function onPollLock(slot: TGSlot): Promise<void> {
|
||||
const state = polls.get(slot.tgHour);
|
||||
if (!state || state.locked) return;
|
||||
|
||||
lockPoll(slot.tgHour);
|
||||
|
||||
const channel = await client.channels.fetch(cfg("pollChannelId")) as any;
|
||||
if (!channel) return;
|
||||
|
||||
// Buttons disabled, no submit button yet — that comes at close
|
||||
await updatePollMessage(channel, slot.tgHour);
|
||||
console.log(`[${new Date().toISOString()}] Poll locked for ${slot.tgHour}:00.`);
|
||||
}
|
||||
|
||||
// Fires at tgHour + closesAfter (e.g. 20:35) — TG ended, reveal Submit Score
|
||||
async function onPollClose(slot: TGSlot): Promise<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;
|
||||
|
||||
const channel = await client.channels.fetch(cfg("pollChannelId")) as any;
|
||||
if (!channel) return;
|
||||
const { updatePollMessage } = require("./systems/poll");
|
||||
await updatePollMessage(channel, slot.tgHour);
|
||||
|
||||
await updatePollMessage(channel, slot.tgHour, undefined, true); // showSubmit = true
|
||||
console.log(`[${new Date().toISOString()}] Poll closed for ${slot.tgHour}:00.`);
|
||||
}
|
||||
|
||||
|
|
@ -51,20 +66,34 @@ 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
|
||||
const restored = persist.load();
|
||||
if (restored) {
|
||||
for (const [slot, state] of restored) polls.set(slot, state);
|
||||
|
||||
// Re-render all restored poll messages
|
||||
const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
for (const slot of polls.keys()) {
|
||||
const state = polls.get(slot)!;
|
||||
await updatePollMessage(channel, slot, undefined, state.locked && state.confirmed === null);
|
||||
}
|
||||
console.log("Poll state restored and messages re-rendered.");
|
||||
}
|
||||
|
||||
const guild = await client.guilds.fetch(GUILD_ID);
|
||||
await guild.members.fetch();
|
||||
console.log(`Member cache warmed: ${guild.members.cache.size} members`);
|
||||
|
||||
if (process.argv.includes("--register")) {
|
||||
await registerCommands();
|
||||
}
|
||||
|
||||
// Schedule slots
|
||||
scheduleSlots(client, onPollOpen, onPollClose);
|
||||
scheduleSlots(client, onPollOpen, onPollLock, onPollClose);
|
||||
|
||||
console.log("Bot ready.");
|
||||
});
|
||||
|
|
|
|||
33
src/scheduler.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import cron from "node-cron";
|
||||
import { TextChannel } from "discord.js";
|
||||
|
||||
// Lock poll at TG start (20:00), reveal Submit Score button at TG end (20:35)
|
||||
// Runs daily — no-ops silently if no poll is active for that slot.
|
||||
|
||||
cron.schedule("0 20 * * *", async () => {
|
||||
const slot = 20;
|
||||
const state = polls.get(slot);
|
||||
if (!state || state.locked) return;
|
||||
|
||||
lockPoll(slot);
|
||||
|
||||
const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot, cfg("lockMessage"));
|
||||
console.log(`[${new Date().toISOString()}] Poll locked for ${slot}:00.`);
|
||||
});
|
||||
|
||||
cron.schedule("35 20 * * *", async () => {
|
||||
const slot = 20;
|
||||
const state = polls.get(slot);
|
||||
if (!state) return;
|
||||
|
||||
const channel = await client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot, undefined, true); // showSubmit = true
|
||||
console.log(`[${new Date().toISOString()}] Submit Score button shown for ${slot}:00 TG.`);
|
||||
});
|
||||
|
||||
// ─── NOTE on future slots ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Right now only slot 20 has an active poll. When we add more votable slots,
|
||||
// pull the active slot from cfg("slots").filter(s => s.active) and schedule
|
||||
// dynamically, or make the cron time configurable in config.json.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { clearBringerOverride } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
import { clearBringerOverride } from "@systems/wrank";
|
||||
import { replyAndDelete } from "@utils";
|
||||
import { Nation } from "@types";
|
||||
|
||||
export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { setBringerOverride } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { Nation } from "../../types";
|
||||
import { setBringerOverride } from "@systems/wrank";
|
||||
import { replyAndDelete } from "@utils";
|
||||
import { Nation } from "@types";
|
||||
|
||||
export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
const usermapKey = interaction.options.getString("name", true);
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
const charName = interaction.options.getString("name", true);
|
||||
|
||||
setBringerOverride(nation, usermapKey);
|
||||
return void replyAndDelete(interaction, `✅ **${usermapKey}** set as ${nation === "Capella" ? "Luminous" : "Storm"} Bringer for this week.`);
|
||||
setBringerOverride(nation, charName);
|
||||
return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`);
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ async function acceptBorrow(
|
|||
const state = polls.get(slot)!;
|
||||
for (const map of [state.yes, state.no]) {
|
||||
for (const [, entry] of map) {
|
||||
if (entry.usermapKey === requesterKey) {
|
||||
if (entry.userKey === requesterKey) {
|
||||
entry.characterName = char.name;
|
||||
entry.characterClass = char.class;
|
||||
entry.characterLevel = char.level;
|
||||
|
|
|
|||
29
src/subcommands/char/active.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { getCharacterByName, getActiveCharacter } from "../../systems/characters";
|
||||
import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleCharActive(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const group = interaction.options.getSubcommandGroup(false);
|
||||
const sub = interaction.options.getSubcommand();
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can check other players' active character.");
|
||||
}
|
||||
const targetKey = nameArg ?? (await resolveUser(member)).userKey;
|
||||
if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const { getEffectiveCharacter } = require("../../systems/borrow");
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(targetKey);
|
||||
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`);
|
||||
|
||||
const { getClassEmoji } = require("../../systems/emojis");
|
||||
const classEmoji = getClassEmoji(char.class) || char.class;
|
||||
const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||
return void replyAndDelete(interaction, `${classEmoji} ${char.level} ${char.name}${borrowed}`);
|
||||
}
|
||||
|
|
@ -14,18 +14,18 @@ export async function handleCharAdd(interaction: ChatInputCommandInteraction): P
|
|||
const level = interaction.options.getInteger("level", true);
|
||||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const added = addCharacter(usermapKey, { name: charName, class: cls, level, nation });
|
||||
const added = addCharacter(userKey, { name: charName, class: cls, level, nation });
|
||||
if (!added) return replyAndDelete(interaction, `❌ A character named **${charName}** already exists.`);
|
||||
|
||||
return replyAndDelete(interaction, `✅ Character **«${charName}»** (${cls} · Lv${level} · ${nation}) added.`);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
|
|||
return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly.");
|
||||
}
|
||||
|
||||
const requesterKey = targetArg ?? requester.usermapKey;
|
||||
const requesterKey = targetArg ?? requester.userKey;
|
||||
if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const char = getCharacterByName(ownerArg, charName);
|
||||
|
|
@ -42,7 +42,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction)
|
|||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
// Find the voter entry and update their character
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.usermapKey === requesterKey) {
|
||||
if (entry.userKey === requesterKey) {
|
||||
entry.characterName = char.name;
|
||||
entry.characterClass = char.class;
|
||||
entry.characterLevel = char.level;
|
||||
|
|
|
|||
|
|
@ -10,18 +10,18 @@ export async function handleCharRemove(interaction: ChatInputCommandInteraction)
|
|||
const nameArg = interaction.options.getString("name");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const removed = removeCharacter(usermapKey, charName);
|
||||
const removed = removeCharacter(userKey, charName);
|
||||
if (!removed) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ Character **«${charName}»** removed.`);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ import path from "path";
|
|||
|
||||
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
||||
|
||||
function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; charName: string } | null {
|
||||
function findSharedChar(userKey: string, charName: string): { ownerKey: string; charName: string } | null {
|
||||
try {
|
||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
if (ownerKey === usermapKey) continue;
|
||||
if (ownerKey === userKey) continue;
|
||||
const char = data.characters?.find(
|
||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey)
|
||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey)
|
||||
);
|
||||
if (char) return { ownerKey, charName: char.name };
|
||||
}
|
||||
|
|
@ -29,25 +29,25 @@ export async function handleCharSetActive(interaction: ChatInputCommandInteracti
|
|||
const nameArg = interaction.options.getString("name");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
// Try own characters first
|
||||
const set = setActiveCharacter(usermapKey, charName);
|
||||
const set = setActiveCharacter(userKey, charName);
|
||||
if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`);
|
||||
|
||||
// Fall back to shared characters
|
||||
const shared = findSharedChar(usermapKey, charName);
|
||||
const shared = findSharedChar(userKey, charName);
|
||||
if (shared) {
|
||||
setSessionBorrow(usermapKey, shared.ownerKey, shared.charName);
|
||||
setSessionBorrow(userKey, shared.ownerKey, shared.charName);
|
||||
return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,21 +12,21 @@ export async function handleCharSetNation(interaction: ChatInputCommandInteracti
|
|||
const nation = interaction.options.getString("nation", true) as Nation;
|
||||
const charName = interaction.options.getString("char_name"); // optional, defaults to active
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const targetName = charName ?? getActiveCharacter(usermapKey)?.name;
|
||||
const targetName = charName ?? getActiveCharacter(userKey)?.name;
|
||||
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
||||
|
||||
const set = setCharacterNation(usermapKey, targetName, nation);
|
||||
const set = setCharacterNation(userKey, targetName, nation);
|
||||
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
||||
|
||||
return replyAndDelete(interaction, `✅ **«${targetName}»** nation set to **${nation}**.`);
|
||||
|
|
|
|||
|
|
@ -13,21 +13,21 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio
|
|||
const def = interaction.options.getInteger("def") ?? undefined;
|
||||
const heal = interaction.options.getInteger("heal") ?? undefined;
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const targetName = charName ?? getActiveCharacter(usermapKey)?.name;
|
||||
const targetName = charName ?? getActiveCharacter(userKey)?.name;
|
||||
if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name.");
|
||||
|
||||
const set = setCharacterStats(usermapKey, targetName, { atk, def, heal });
|
||||
const set = setCharacterStats(userKey, targetName, { atk, def, heal });
|
||||
if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`);
|
||||
|
||||
return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { getCharacterByName } from "../../systems/characters";
|
|||
import { replyAndDelete } from "../../utils";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { clearPersistentPreference } from "../../systems/borrow";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../../data/characters.json");
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
|
|||
return void replyAndDelete(interaction, "❌ Only officers can share other players' characters.");
|
||||
}
|
||||
|
||||
const ownerKey = ownerArg ?? user.usermapKey;
|
||||
const ownerKey = ownerArg ?? user.userKey;
|
||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const char = getCharacterByName(ownerKey, charName);
|
||||
|
|
@ -48,7 +49,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction):
|
|||
charEntry.sharedWith.push(targetKey);
|
||||
saveCharacters(raw);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ **«${charName}»** is now permanently shared with **${targetKey}**.`);
|
||||
return void replyAndDelete(interaction, `✅ **${charName}** is now permanently shared with **${targetKey}**.`);
|
||||
}
|
||||
|
||||
export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
|
|
@ -64,7 +65,7 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction
|
|||
return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares.");
|
||||
}
|
||||
|
||||
const ownerKey = ownerArg ?? user.usermapKey;
|
||||
const ownerKey = ownerArg ?? user.userKey;
|
||||
if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const raw = loadRawChars();
|
||||
|
|
@ -78,5 +79,8 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction
|
|||
charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey);
|
||||
saveCharacters(raw);
|
||||
|
||||
// Clear persistent preference if the borrower was using this char
|
||||
clearPersistentPreference(targetKey);
|
||||
|
||||
return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`);
|
||||
}
|
||||
126
src/subcommands/impersonate.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import {
|
||||
ChatInputCommandInteraction,
|
||||
ButtonInteraction,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ActionRowBuilder,
|
||||
EmbedBuilder,
|
||||
} from "discord.js";
|
||||
import { cfg } from "@systems/config";
|
||||
import { hasOfficerRole } from "@systems/users";
|
||||
import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate";
|
||||
import { replyAndDelete } from "@utils";
|
||||
|
||||
const PAGE_SIZE = 5; // users per page (1 button each + nav row)
|
||||
|
||||
function buildImpersonateEmbed(users: { userKey: string; aliases: string[] }[], page: number, currentImpersonation: string | null): EmbedBuilder {
|
||||
const start = page * PAGE_SIZE;
|
||||
const pageUsers = users.slice(start, start + PAGE_SIZE);
|
||||
|
||||
const lines = pageUsers.map((u, i) => {
|
||||
const display = u.aliases[0] ?? u.userKey;
|
||||
const current = u.userKey === currentImpersonation ? " ← *active*" : "";
|
||||
return `${start + i + 1}. **${display}** (${u.userKey})${current}`;
|
||||
});
|
||||
|
||||
return new EmbedBuilder()
|
||||
.setTitle("🎭 Impersonate User")
|
||||
.setDescription(
|
||||
(currentImpersonation
|
||||
? `Currently impersonating: **${currentImpersonation}**\n\n`
|
||||
: "Not impersonating anyone.\n\n") +
|
||||
lines.join("\n")
|
||||
)
|
||||
.setColor(0x5865f2)
|
||||
.setFooter({ text: `Page ${page + 1} of ${Math.ceil(users.length / PAGE_SIZE)}` });
|
||||
}
|
||||
|
||||
function buildImpersonateButtons(
|
||||
users: { userKey: string; aliases: string[] }[],
|
||||
page: number,
|
||||
realDiscordId: string
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const start = page * PAGE_SIZE;
|
||||
const pageUsers = users.slice(start, start + PAGE_SIZE);
|
||||
const hasNext = users.length > (page + 1) * PAGE_SIZE;
|
||||
const hasPrev = page > 0;
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
// One button per user on this page
|
||||
const userButtons = pageUsers.map((u) =>
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`impersonate_set:${u.userKey}:${page}`)
|
||||
.setLabel(`${u.aliases[0] ?? u.userKey}`)
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
if (userButtons.length > 0) {
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...userButtons));
|
||||
}
|
||||
|
||||
// Nav + release row
|
||||
const navBtns: ButtonBuilder[] = [];
|
||||
if (hasPrev) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page - 1}`).setLabel("← Prev").setStyle(ButtonStyle.Secondary));
|
||||
if (hasNext) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page + 1}`).setLabel("Next →").setStyle(ButtonStyle.Secondary));
|
||||
navBtns.push(new ButtonBuilder().setCustomId("impersonate_release").setLabel("🚫 Release").setStyle(ButtonStyle.Danger));
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navBtns));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Slash command handler
|
||||
export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
if (!hasOfficerRole(member, cfg("officerRoles"))) {
|
||||
return void replyAndDelete(interaction, "❌ You don't have permission to use this command.");
|
||||
}
|
||||
|
||||
const users = getRegisteredUsers();
|
||||
if (users.length === 0) return void replyAndDelete(interaction, "❌ No registered users found in usermap.json.");
|
||||
|
||||
const current = getImpersonation(interaction.user.id);
|
||||
const embed = buildImpersonateEmbed(users, 0, current);
|
||||
const buttons = buildImpersonateButtons(users, 0, interaction.user.id);
|
||||
|
||||
await interaction.reply({ embeds: [embed], components: buttons, ephemeral: true });
|
||||
}
|
||||
|
||||
// Button handler
|
||||
export async function handleImpersonateButton(interaction: ButtonInteraction): Promise<void> {
|
||||
const { customId } = interaction;
|
||||
const realId = interaction.user.id;
|
||||
|
||||
if (customId.startsWith("impersonate_set:")) {
|
||||
const parts = customId.split(":");
|
||||
const userKey = parts[1];
|
||||
const page = parseInt(parts[2] ?? "0");
|
||||
|
||||
setImpersonation(realId, userKey);
|
||||
const users = getRegisteredUsers();
|
||||
const embed = buildImpersonateEmbed(users, page, userKey);
|
||||
const buttons = buildImpersonateButtons(users, page, realId);
|
||||
|
||||
await interaction.update({ embeds: [embed], components: buttons });
|
||||
return;
|
||||
}
|
||||
|
||||
if (customId.startsWith("impersonate_page:")) {
|
||||
const page = parseInt(customId.split(":")[1]);
|
||||
const current = getImpersonation(realId);
|
||||
const users = getRegisteredUsers();
|
||||
const embed = buildImpersonateEmbed(users, page, current);
|
||||
const buttons = buildImpersonateButtons(users, page, realId);
|
||||
|
||||
await interaction.update({ embeds: [embed], components: buttons });
|
||||
return;
|
||||
}
|
||||
|
||||
if (customId === "impersonate_release") {
|
||||
clearImpersonation(realId);
|
||||
const users = getRegisteredUsers();
|
||||
const embed = buildImpersonateEmbed(users, 0, null);
|
||||
const buttons = buildImpersonateButtons(users, 0, realId);
|
||||
|
||||
await interaction.update({ embeds: [embed], components: buttons });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { getActiveCharacter } from "../../systems/characters";
|
||||
import { resolveNation } from "../../systems/nations";
|
||||
import { getEffectiveCharacter } from "../../systems/borrow";
|
||||
import { nowFormatted, resolveMessage } from "../../systems/messages";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { VoteEntry } from "../../types";
|
||||
|
||||
export async function handleInject(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const usermapKey = interaction.options.getString("name", true);
|
||||
const userKey = interaction.options.getString("name", true);
|
||||
const voteType = interaction.options.getString("vote_type", true) as "yes" | "no";
|
||||
|
||||
console.log("[inject] called");
|
||||
const slot = [...polls.keys()][0];
|
||||
console.log("[inject] slot:", slot);
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
|
|
@ -19,23 +20,24 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr
|
|||
return void replyAndDelete(interaction, "❌ Poll is locked or confirmed.");
|
||||
}
|
||||
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
console.log(`[DEBUG inject] usermapKey=${usermapKey} char=${JSON.stringify(char)}`);
|
||||
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${usermapKey}**.`);
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||
console.log(`[DEBUG inject] userKey=${userKey} char=${JSON.stringify(char)} borrowedFrom=${JSON.stringify(borrowedFrom)}`);
|
||||
if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${userKey}**.`);
|
||||
|
||||
// Use a synthetic userId based on usermapKey to avoid collisions
|
||||
const syntheticId = `injected:${usermapKey}`;
|
||||
// Use a synthetic userId based on userKey to avoid collisions
|
||||
const syntheticId = `injected:${userKey}`;
|
||||
const now = nowFormatted();
|
||||
|
||||
const publicMsg = resolveMessage("public", voteType, 1, usermapKey, null, null);
|
||||
const publicMsg = resolveMessage("public", voteType, 1, userKey, null, null);
|
||||
|
||||
const entry: VoteEntry = {
|
||||
usermapKey,
|
||||
const entry: VoteEntry = {
|
||||
userKey,
|
||||
displayName: char.name,
|
||||
characterName: char.name,
|
||||
characterClass: char.class,
|
||||
characterLevel: char.level,
|
||||
characterNation: char.nation,
|
||||
borrowedFrom: borrowedFrom ?? undefined,
|
||||
votedAt: now,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
};
|
||||
|
|
@ -50,31 +52,31 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr
|
|||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
return void replyAndDelete(interaction, `✅ Injected **${usermapKey}** as **${voteType}**.`);
|
||||
return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`);
|
||||
}
|
||||
|
||||
export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const usermapKey = interaction.options.getString("name", true);
|
||||
const userKey = interaction.options.getString("name", true);
|
||||
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
const syntheticId = `injected:${usermapKey}`;
|
||||
const syntheticId = `injected:${userKey}`;
|
||||
|
||||
// Also try removing real votes by scanning for usermapKey
|
||||
// Also try removing real votes by scanning for userKey
|
||||
let removed = false;
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.usermapKey === usermapKey || id === syntheticId) {
|
||||
if (entry.userKey === userKey || id === syntheticId) {
|
||||
state.yes.delete(id);
|
||||
state.no.delete(id);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${usermapKey}**.`);
|
||||
if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${userKey}**.`);
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot);
|
||||
return void replyAndDelete(interaction, `✅ Vote removed for **${usermapKey}**.`);
|
||||
return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`);
|
||||
}
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { polls, updatePollMessage } from "../../systems/poll";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { cfg } from "@systems/config";
|
||||
import { polls, lockPoll, updatePollMessage } from "@systems/poll";
|
||||
import { replyAndDelete } from "@utils";
|
||||
|
||||
export async function handleLock(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const oneTimeMsg = interaction.options.getString("message") ?? undefined;
|
||||
const slot = getActiveSlot();
|
||||
if (!slot) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
const oneTimeMsg = interaction.options.getString("message") ?? undefined;
|
||||
const simulateClose = interaction.options.getBoolean("simulate_close") ?? false;
|
||||
const slot = getActiveSlot();
|
||||
if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found.");
|
||||
|
||||
const state = polls.get(slot)!;
|
||||
state.locked = true;
|
||||
// Use lockPoll() so lockedYesKeys gets snapshotted — same path as the cron
|
||||
lockPoll(slot);
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
|
||||
if (simulateClose) {
|
||||
// Simulate TG end: show Submit Score button (same path as onPollClose cron)
|
||||
await updatePollMessage(channel, slot, oneTimeMsg, true);
|
||||
return void replyAndDelete(interaction, "🔒 Poll locked + 📊 Submit Score button shown (simulate close).");
|
||||
}
|
||||
|
||||
// Normal lock: voting closes, no submit button yet
|
||||
await updatePollMessage(channel, slot, oneTimeMsg);
|
||||
return void replyAndDelete(interaction, "🔒 Poll locked.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,88 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { loadMessages } from "../../systems/messages";
|
||||
import { loadEmojis } from "../../systems/emojis";
|
||||
import { loadCharacters } from "../../systems/characters";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { loadMessages } from "@systems/messages";
|
||||
import { loadEmojis } from "@systems/emojis";
|
||||
import { loadCharacters } from "@systems/characters";
|
||||
import { loadWRank } from "@systems/wrank";
|
||||
import { loadConfig, cfg } from "@systems/config";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { persist } from "@systems/pollPersistence";
|
||||
import { replyAndDelete } from "@utils";
|
||||
|
||||
const RELOADABLE = ["all", "messages", "emojis", "characters", "wrank", "config", "poll"] as const;
|
||||
type Reloadable = typeof RELOADABLE[number];
|
||||
|
||||
export async function handleReload(interaction: ChatInputCommandInteraction): Promise<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.");
|
||||
const target = (interaction.options.getString("target") ?? "all") as Reloadable;
|
||||
|
||||
const reloaded: string[] = [];
|
||||
|
||||
const should = (k: Reloadable) => target === "all" || target === k;
|
||||
|
||||
if (should("config")) { loadConfig(); reloaded.push("config"); }
|
||||
if (should("messages")) { loadMessages(); reloaded.push("messages"); }
|
||||
if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); }
|
||||
if (should("characters")) { loadCharacters(); reloaded.push("characters"); }
|
||||
if (should("wrank")) { loadWRank(); reloaded.push("wrank"); }
|
||||
|
||||
// Re-render active poll message(s) so embed reflects reloaded data
|
||||
if (should("poll") || should("emojis") || should("all")) {
|
||||
const channelId = cfg("pollChannelId");
|
||||
if (channelId) {
|
||||
try {
|
||||
// Restore from disk first if reloading poll specifically
|
||||
if (should("poll")) {
|
||||
const restored = persist.load();
|
||||
if (restored) {
|
||||
polls.clear();
|
||||
for (const [slot, state] of restored) polls.set(slot, state);
|
||||
}
|
||||
}
|
||||
if (polls.size > 0) {
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
for (const slot of polls.keys()) {
|
||||
const state = polls.get(slot)!;
|
||||
const showSubmit = state.locked && state.confirmed === null;
|
||||
await updatePollMessage(channel, slot, undefined, showSubmit);
|
||||
}
|
||||
reloaded.push("poll message");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to re-render poll message on reload:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`);
|
||||
}
|
||||
|
||||
// export async function handleReload(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
// const target = (interaction.options.getString("target") ?? "all") as Reloadable;
|
||||
|
||||
// const reloaded: string[] = [];
|
||||
|
||||
// const should = (k: Reloadable) => target === "all" || target === k;
|
||||
|
||||
// if (should("config")) { loadConfig(); reloaded.push("config"); }
|
||||
// if (should("messages")) { loadMessages(); reloaded.push("messages"); }
|
||||
// if (should("emojis")) { loadEmojis(); reloaded.push("emojis"); }
|
||||
// if (should("characters")) { loadCharacters(); reloaded.push("characters"); }
|
||||
// if (should("wrank")) { loadWRank(); reloaded.push("wrank"); }
|
||||
|
||||
// // Re-render active poll message(s) so embed reflects reloaded data
|
||||
// if (should("poll") || should("emojis") || should("all")) {
|
||||
// const channelId = cfg("pollChannelId");
|
||||
// if (channelId && polls.size > 0) {
|
||||
// try {
|
||||
// const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
// for (const slot of polls.keys()) {
|
||||
// await updatePollMessage(channel, slot);
|
||||
// }
|
||||
// reloaded.push("poll message");
|
||||
// } catch (err) {
|
||||
// console.error("Failed to re-render poll message on reload:", err);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return void replyAndDelete(interaction, `🔄 Reloaded: ${reloaded.join(", ")}.`);
|
||||
// }
|
||||
12
src/subcommands/poll/reload.ts.bak
Normal 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.");
|
||||
}
|
||||
|
|
@ -29,18 +29,18 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom
|
|||
let skipped = 0;
|
||||
|
||||
for (const [discordUsername, entry] of Object.entries(usermap)) {
|
||||
const usermapKey = typeof entry === "string" ? entry : entry.file;
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(usermapKey);
|
||||
const userKey = typeof entry === "string" ? entry : entry.file;
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||
|
||||
if (!char) { skipped++; continue; }
|
||||
|
||||
const syntheticId = `injected:${usermapKey}`;
|
||||
const syntheticId = `injected:${userKey}`;
|
||||
if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; }
|
||||
|
||||
const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null);
|
||||
|
||||
const voteEntry: VoteEntry = {
|
||||
usermapKey,
|
||||
userKey,
|
||||
displayName: char.name,
|
||||
characterName: char.name,
|
||||
characterClass: char.class,
|
||||
|
|
|
|||
|
|
@ -13,29 +13,29 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P
|
|||
return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks.");
|
||||
}
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const week = getCurrentWeek();
|
||||
const goal = cfg("wRankGoal");
|
||||
const weekKey = getWeekKey();
|
||||
|
||||
for (const nation of ["capella", "procyon"] as const) {
|
||||
const entry = week.entries[nation].find((e) => e.usermapKey === usermapKey);
|
||||
const entry = week.entries[nation].find((e) => e.userKey === userKey);
|
||||
if (!entry) continue;
|
||||
|
||||
const isDone = entry.tgCount >= goal;
|
||||
const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0;
|
||||
const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : "";
|
||||
const bringer = getBringer(entry.nation);
|
||||
const isBringer = bringer === usermapKey && isDone;
|
||||
const isBringer = bringer === userKey && isDone;
|
||||
|
||||
const lines = [
|
||||
`**${entry.characterName}** · ${entry.nation}`,
|
||||
|
|
@ -48,5 +48,5 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P
|
|||
return void replyAndDelete(interaction, lines);
|
||||
}
|
||||
|
||||
return void replyAndDelete(interaction, `❌ No rank found for **${usermapKey}** this week.`);
|
||||
return void replyAndDelete(interaction, `❌ No rank found for **${userKey}** this week.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { ChatInputCommandInteraction, TextChannel, EmbedBuilder } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { getCurrentWeek, getWeekKey, getBringer } from "../../systems/wrank";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { cfg } from "@systems/config";
|
||||
import { getCurrentWeek, getWeekKey, getBringer } from "@systems/wrank";
|
||||
import { getEmoji } from "@systems/emojis";
|
||||
import { replyAndDelete } from "@utils";
|
||||
import { format } from "@src/systems/format";
|
||||
|
||||
export async function handleRankPost(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
const week = getCurrentWeek();
|
||||
|
|
@ -11,27 +13,51 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction):
|
|||
const formatNation = (nation: "capella" | "procyon"): string => {
|
||||
const entries = [...week.entries[nation]].sort((a, b) => a.currentRank - b.currentRank);
|
||||
if (entries.length === 0) return "—";
|
||||
|
||||
const bringer = getBringer(nation === "capella" ? "Capella" : "Procyon");
|
||||
|
||||
return entries.map((e) => {
|
||||
const isDone = e.tgCount >= goal;
|
||||
const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0;
|
||||
const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : "";
|
||||
const bringerStr = bringer === e.usermapKey && isDone
|
||||
const isDone = e.tgCount >= goal;
|
||||
|
||||
// ── Character indicator ───────────────────────────────────────────────────
|
||||
const charStr = format.char({ class: e.class, level: 79, name: e.characterName });
|
||||
|
||||
// ── Rank indicator ───────────────────────────────────────────────────
|
||||
const rankStr = format.wrank.rank(e, goal);
|
||||
const deltaStr = format.wrank.delta(e, { brackets: false });
|
||||
|
||||
// ── Bringer label ────────────────────────────────────────────────────
|
||||
const bringerStr = bringer === e.userKey && isDone
|
||||
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
|
||||
: "";
|
||||
return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`;
|
||||
}).join("\n");
|
||||
|
||||
// ── Score indicator ───────────────────────────────────────────────────
|
||||
const scoreStr = format.score(e.weeklyPoints);
|
||||
|
||||
return `${rankStr}(${deltaStr}) ${charStr} — ${scoreStr} ${bringerStr}`;
|
||||
}).join("\n");
|
||||
// return `${rankStr}(${deltaStr}) ${e.characterName} — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`;
|
||||
// }).join("\n");
|
||||
};
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`)
|
||||
.setTitle(`⚔️ TG Leaderboard — ${weekKey}`)
|
||||
.setColor(0xe8a317)
|
||||
.addFields(
|
||||
{ name: "🔵 Capella", value: formatNation("capella"), inline: true },
|
||||
{ name: "🔴 Procyon", value: formatNation("procyon"), inline: true },
|
||||
{ name: format.nation("Capella"), value: formatNation("capella"), inline: true },
|
||||
{ name: format.nation("Procyon"), value: formatNation("procyon"), inline: true },
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
// const embed = new EmbedBuilder()
|
||||
// .setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`)
|
||||
// .setColor(0xe8a317)
|
||||
// .addFields(
|
||||
// { name: "🔵 Capella", value: formatNation("capella"), inline: true },
|
||||
// { name: "🔴 Procyon", value: formatNation("procyon"), inline: true },
|
||||
// )
|
||||
// .setTimestamp();
|
||||
|
||||
const channelId = cfg("resultsChannelId") || cfg("pollChannelId");
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
await channel.send({ embeds: [embed] });
|
||||
|
|
|
|||
39
src/subcommands/rank/post.ts.bak
Normal 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.userKey && isDone
|
||||
? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}`
|
||||
: "";
|
||||
return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`;
|
||||
}).join("\n");
|
||||
};
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(`⚔️ W.Rank Leaderboard — ${weekKey}`)
|
||||
.setColor(0xe8a317)
|
||||
.addFields(
|
||||
{ name: "🔵 Capella", value: formatNation("capella"), inline: true },
|
||||
{ name: "🔴 Procyon", value: formatNation("procyon"), inline: true },
|
||||
)
|
||||
.setTimestamp();
|
||||
|
||||
const channelId = cfg("resultsChannelId") || cfg("pollChannelId");
|
||||
const channel = await interaction.client.channels.fetch(channelId) as TextChannel;
|
||||
await channel.send({ embeds: [embed] });
|
||||
return void replyAndDelete(interaction, "✅ Leaderboard posted.");
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { cfg } from "../../systems/config";
|
|||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { normalizeSlot, detectSlot } from "../../systems/scores";
|
||||
import { loadResult, todayString } from "../../systems/history";
|
||||
import { getEmoji } from "../../systems/emojis";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
|
||||
export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise<void> {
|
||||
|
|
@ -12,41 +13,51 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction):
|
|||
const slotArg = interaction.options.getString("slot");
|
||||
|
||||
if (nameArg && !isOfficer) {
|
||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.");
|
||||
return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.", true);
|
||||
}
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.", true);
|
||||
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true);
|
||||
} else {
|
||||
slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20;
|
||||
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
const result = loadResult(todayString(), slot);
|
||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`);
|
||||
if (!result) return void replyAndDelete(interaction, `❌ No result found for **${slot}:00** TG today.`, true);
|
||||
|
||||
const score = result.scores.find((s) => s.usermapKey === usermapKey);
|
||||
if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${usermapKey}** in the ${slot}:00 TG.`);
|
||||
// Find score — check both direct ownership and borrowed (playedBy)
|
||||
const score = result.scores.find(
|
||||
(s) => s.userKey === userKey || (s as any).playedBy === userKey
|
||||
);
|
||||
|
||||
if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${userKey}** in the **${slot}:00** TG.`, true);
|
||||
|
||||
const scoreEmoji = getEmoji("score") || "📊";
|
||||
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||
const playedBy = (score as any).playedBy && (score as any).playedBy !== score.userKey
|
||||
? `\n*(played by ${(score as any).playedBy})*`
|
||||
: "";
|
||||
|
||||
const lines = [
|
||||
`**${score.characterName}** (${score.class} · ${score.nation})`,
|
||||
`**Points:** ${score.pts}`,
|
||||
score.atk !== undefined ? `**ATK:** ${score.atk}` : null,
|
||||
score.def !== undefined ? `**DEF:** ${score.def}` : null,
|
||||
score.heal !== undefined ? `**HEAL:** ${score.heal}` : null,
|
||||
`**Submitted:** ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}`,
|
||||
`**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`,
|
||||
`${scoreEmoji} **${score.pts}** pts`,
|
||||
score.atk !== undefined ? `ATK: ${score.atk}` : null,
|
||||
score.def !== undefined ? `DEF: ${score.def}` : null,
|
||||
score.heal !== undefined ? `HEAL: ${score.heal}` : null,
|
||||
`*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return void replyAndDelete(interaction, lines);
|
||||
return void replyAndDelete(interaction, lines, true);
|
||||
}
|
||||
|
|
@ -1,57 +1,75 @@
|
|||
import { ChatInputCommandInteraction } from "discord.js";
|
||||
import { cfg } from "../../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../../systems/users";
|
||||
import { submitScore, detectSlot, normalizeSlot } from "../../systems/scores";
|
||||
import { getActiveCharacter } from "../../systems/characters";
|
||||
import { resolveNation } from "../../systems/nations";
|
||||
import { replyAndDelete } from "../../utils";
|
||||
import { cfg } from "@systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
|
||||
import { getEffectiveCharacter } from "@systems/borrow";
|
||||
import { replyAndDelete } from "@utils";
|
||||
import { getEmoji } from "@systems/emojis";
|
||||
|
||||
export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise<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;
|
||||
const member = await interaction.guild!.members.fetch(interaction.user.id);
|
||||
const isOfficer = hasOfficerRole(member, cfg("officerRoles"));
|
||||
const nameArg = interaction.options.getString("name");
|
||||
const ptsArg = interaction.options.getInteger("pts", true);
|
||||
const slotArg = interaction.options.getString("slot");
|
||||
const kills = interaction.options.getInteger("k") ?? undefined;
|
||||
const deaths = interaction.options.getInteger("d") ?? undefined;
|
||||
const k = interaction.options.getInteger("k") ?? undefined;
|
||||
const d = interaction.options.getInteger("d") ?? undefined;
|
||||
const atk = interaction.options.getInteger("atk") ?? undefined;
|
||||
const def = interaction.options.getInteger("def") ?? undefined;
|
||||
const heal = interaction.options.getInteger("heal") ?? undefined;
|
||||
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||
if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first.");
|
||||
|
||||
// Resolve slot
|
||||
let slot: number | null = null;
|
||||
if (slotArg) {
|
||||
slot = normalizeSlot(slotArg);
|
||||
if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`);
|
||||
} else {
|
||||
slot = detectSlot();
|
||||
if (slot === null) {
|
||||
return void replyAndDelete(interaction, "❌ No active score window detected. Specify a slot explicitly.");
|
||||
}
|
||||
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
await submitScore({
|
||||
usermapKey,
|
||||
userKey: borrowedFrom ?? userKey,
|
||||
playedBy: borrowedFrom ? userKey : undefined,
|
||||
characterName: char.name,
|
||||
cls: char.class,
|
||||
nation: char.nation,
|
||||
pts: ptsArg,
|
||||
k,
|
||||
d,
|
||||
slot,
|
||||
atk,
|
||||
def,
|
||||
heal,
|
||||
submittedByOfficer: isOfficer && !!nameArg,
|
||||
});
|
||||
|
||||
return void replyAndDelete(interaction, `✅ Score of **${ptsArg}** submitted for **${char.name}** (${slot}:00 TG).`);
|
||||
const scoreEmoji = getEmoji("score") || "📊";
|
||||
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
|
||||
const kdNote = kills !== undefined && deaths !== undefined ? `\n${kdEmoji} ${kills}/${deaths}` : "";
|
||||
const statsNote = [
|
||||
atk !== undefined ? `ATK: ${atk}` : null,
|
||||
def !== undefined ? `DEF: ${def}` : null,
|
||||
heal !== undefined ? `HEAL: ${heal}` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
return void replyAndDelete(interaction,
|
||||
`✅ ${scoreEmoji} **${ptsArg}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
|
||||
true
|
||||
);
|
||||
}
|
||||
86
src/subcommands/score/submitCore.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { cfg } from "@systems/config";
|
||||
import { submitScore, detectSlot, normalizeSlot } from "@systems/scores";
|
||||
import { getEffectiveCharacter } from "@systems/borrow";
|
||||
import { format } from "@format";
|
||||
import { getEmoji } from "@systems/emojis";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ScoreSubmitInput {
|
||||
userKey: string;
|
||||
pts: number;
|
||||
slot?: number | string | null; // number = already resolved, string = needs normalizing, null/undefined = auto-detect
|
||||
k?: number;
|
||||
d?: number;
|
||||
atk?: number;
|
||||
def?: number;
|
||||
heal?: number;
|
||||
submittedByOfficer?: boolean;
|
||||
}
|
||||
|
||||
export type ScoreSubmitResult =
|
||||
| { ok: true; message: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
// ─── Core ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export namespace score {
|
||||
/**
|
||||
* Resolve, validate and persist a score submission for a given userKey.
|
||||
* Used by both the slash command handler and the modal submit handler.
|
||||
*/
|
||||
export async function submitForUser(input: ScoreSubmitInput): Promise<ScoreSubmitResult> {
|
||||
const { userKey, pts, k, d, atk, def, heal, submittedByOfficer = false } = input;
|
||||
|
||||
const { char, borrowedFrom } = getEffectiveCharacter(userKey);
|
||||
if (!char) {
|
||||
return { ok: false, message: "❌ No active character found. Use `/tg char set-active` first." };
|
||||
}
|
||||
|
||||
// Resolve slot
|
||||
let slot: number | null = null;
|
||||
if (typeof input.slot === "number") {
|
||||
slot = input.slot;
|
||||
} else if (typeof input.slot === "string") {
|
||||
slot = normalizeSlot(input.slot);
|
||||
if (slot === null) {
|
||||
return { ok: false, message: `❌ Could not parse slot "${input.slot}".` };
|
||||
}
|
||||
} else {
|
||||
slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20;
|
||||
}
|
||||
|
||||
await submitScore({
|
||||
userKey: borrowedFrom ?? userKey,
|
||||
playedBy: borrowedFrom ? userKey : undefined,
|
||||
characterName: char.name,
|
||||
cls: char.class,
|
||||
nation: char.nation,
|
||||
pts,
|
||||
k,
|
||||
d,
|
||||
slot,
|
||||
atk,
|
||||
def,
|
||||
heal,
|
||||
submittedByOfficer,
|
||||
});
|
||||
|
||||
const scoreEmoji = getEmoji("score") || "📊";
|
||||
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||
const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : "";
|
||||
const kdNote = k !== undefined && d !== undefined ? `\n${kdEmoji} ${k}/${d}` : "";
|
||||
const statsNote = [
|
||||
atk !== undefined ? `ATK: ${atk}` : null,
|
||||
def !== undefined ? `DEF: ${def}` : null,
|
||||
heal !== undefined ? `HEAL: ${heal}` : null,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
const charDisplay = format.char(char);
|
||||
return {
|
||||
ok: true,
|
||||
message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
|
||||
// message: `✅ ${scoreEmoji} **${pts}** submitted for ${charDisplay}${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,31 @@
|
|||
import { ChatInputCommandInteraction, TextChannel } from "discord.js";
|
||||
import { cfg } from "../systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "../systems/users";
|
||||
import { setActiveCharacter, getActiveCharacter, getCharacterByName } from "../systems/characters";
|
||||
import { setSessionBorrow, getSessionBorrow } from "../systems/borrow";
|
||||
import { polls, updatePollMessage } from "../systems/poll";
|
||||
import { replyAndDelete } from "../utils";
|
||||
import { cfg } from "@systems/config";
|
||||
import { resolveUser, hasOfficerRole } from "@systems/users";
|
||||
import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters";
|
||||
import {
|
||||
getEffectiveCharacter,
|
||||
setSessionBorrow,
|
||||
setPersistentPreference,
|
||||
clearPersistentPreference,
|
||||
clearSessionBorrowForUser,
|
||||
} from "@systems/borrow";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { getClassEmoji } from "@systems/emojis";
|
||||
import { replyAndDelete } from "@src/utils";
|
||||
import { format } from "@format";
|
||||
import { buildCharSelectButtons } from "@systems/charSelect";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
|
||||
|
||||
function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; char: any } | null {
|
||||
function findSharedChar(userKey: string, charName: string): { ownerKey: string; char: any } | null {
|
||||
try {
|
||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
if (ownerKey === usermapKey) continue;
|
||||
if (ownerKey === userKey) continue;
|
||||
const char = data.characters?.find(
|
||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey)
|
||||
(c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey)
|
||||
);
|
||||
if (char) return { ownerKey, char };
|
||||
}
|
||||
|
|
@ -24,10 +33,9 @@ function findSharedChar(usermapKey: string, charName: string): { ownerKey: strin
|
|||
return null;
|
||||
}
|
||||
|
||||
// Reverse-lookup: find Discord userId for a usermapKey from current poll voters
|
||||
function findUserIdInPoll(state: any, usermapKey: string): string | null {
|
||||
function findVoteIdInPoll(state: any, userKey: string): string | null {
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
if (entry.usermapKey === usermapKey) return id;
|
||||
if (entry.userKey === userKey) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -38,49 +46,98 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
|
|||
const nameArg = interaction.options.getString("name");
|
||||
const charName = interaction.options.getString("char_name", true);
|
||||
|
||||
let usermapKey: string | null;
|
||||
let userKey: string | null;
|
||||
if (nameArg) {
|
||||
if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters.");
|
||||
usermapKey = nameArg;
|
||||
userKey = nameArg;
|
||||
} else {
|
||||
const user = await resolveUser(member);
|
||||
usermapKey = user.usermapKey;
|
||||
userKey = user.userKey;
|
||||
}
|
||||
|
||||
if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.");
|
||||
|
||||
// Resolve the target character without switching yet
|
||||
let resolvedChar: any = null;
|
||||
let borrowedFrom: string | null = null;
|
||||
|
||||
// Try own characters first
|
||||
const set = setActiveCharacter(usermapKey, charName);
|
||||
if (set) {
|
||||
resolvedChar = getActiveCharacter(usermapKey);
|
||||
const ownChar = getCharacterByName(userKey, charName);
|
||||
if (ownChar) {
|
||||
resolvedChar = ownChar;
|
||||
} else {
|
||||
// Fall back to shared characters
|
||||
const shared = findSharedChar(usermapKey, charName);
|
||||
const shared = findSharedChar(userKey, charName);
|
||||
if (shared) {
|
||||
setSessionBorrow(usermapKey, shared.ownerKey, shared.char.name);
|
||||
resolvedChar = shared.char;
|
||||
borrowedFrom = shared.ownerKey;
|
||||
console.log(`[borrow] Session borrow set: ${usermapKey} → ${shared.ownerKey}:${shared.char.name}`);
|
||||
console.log(`[borrow] Current borrows:`, getSessionBorrow(usermapKey));
|
||||
resolvedChar = shared.char;
|
||||
borrowedFrom = shared.ownerKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`);
|
||||
|
||||
// Update poll embed if user has voted
|
||||
// If already active — just show current state without switching
|
||||
const current = getEffectiveCharacter(userKey);
|
||||
if (current.char?.name === resolvedChar.name) {
|
||||
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
|
||||
const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : "";
|
||||
return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
|
||||
}
|
||||
|
||||
// Check if target character is already in the active poll by another player
|
||||
const slot = [...polls.keys()][0];
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
const userId = nameArg
|
||||
? findUserIdInPoll(state, usermapKey)
|
||||
: interaction.user.id;
|
||||
const state = polls.get(slot)!;
|
||||
for (const [id, entry] of state.yes.entries()) {
|
||||
const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`;
|
||||
if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) {
|
||||
const slotHour = state.slot;
|
||||
const charDisplay = format.char(resolvedChar);
|
||||
const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name);
|
||||
if (isOwner) {
|
||||
await interaction.reply({
|
||||
content: `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character to trigger the reclaim option, or switch to a different one:`,
|
||||
components: buildCharSelectButtons(userKey, {
|
||||
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||
excludeCharName: resolvedChar.name,
|
||||
appendToCustomId: ":yes",
|
||||
}),
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const buttons = buildCharSelectButtons(userKey, {
|
||||
customIdPrefix: `switch_after_reclaim:${userKey}`,
|
||||
excludeCharName: resolvedChar.name,
|
||||
appendToCustomId: `:${"yes"}`,
|
||||
});
|
||||
await interaction.reply({
|
||||
content: `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`,
|
||||
components: buttons,
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userId && (state.yes.has(userId) || state.no.has(userId))) {
|
||||
// Now actually switch
|
||||
if (borrowedFrom) {
|
||||
setSessionBorrow(userKey, borrowedFrom, resolvedChar.name);
|
||||
setPersistentPreference(userKey, borrowedFrom, resolvedChar.name);
|
||||
} else {
|
||||
setActiveCharacter(userKey, charName);
|
||||
clearPersistentPreference(userKey);
|
||||
clearSessionBorrowForUser(userKey);
|
||||
resolvedChar = getActiveCharacter(userKey);
|
||||
}
|
||||
|
||||
// Update poll embed if user has already voted
|
||||
if (slot !== undefined) {
|
||||
const state = polls.get(slot)!;
|
||||
const voteId = findVoteIdInPoll(state, userKey);
|
||||
|
||||
if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) {
|
||||
const updateEntry = (map: Map<string, any>) => {
|
||||
const entry = map.get(userId);
|
||||
const entry = map.get(voteId);
|
||||
if (entry) {
|
||||
entry.characterName = resolvedChar.name;
|
||||
entry.characterClass = resolvedChar.class;
|
||||
|
|
@ -97,6 +154,7 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr
|
|||
}
|
||||
}
|
||||
|
||||
const borrowNote = borrowedFrom ? ` (shared by **${borrowedFrom}**)` : "";
|
||||
return void replyAndDelete(interaction, `✅ Switched to **${charName}**${borrowNote}.`);
|
||||
const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class;
|
||||
const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : "";
|
||||
return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true);
|
||||
}
|
||||
|
|
@ -1,15 +1,44 @@
|
|||
import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { BorrowRequest } from "../types";
|
||||
import { cfg } from "./config";
|
||||
import { getCharacterByName } from "./characters";
|
||||
|
||||
// Active borrow requests (pending accept/decline)
|
||||
const pendingRequests: Map<string, BorrowRequest> = new Map(); // key: `${ownerKey}:${requesterKey}`
|
||||
const PREFS_PATH = path.join(__dirname, "../../data/sessionPreferences.json");
|
||||
|
||||
// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start
|
||||
// ─── Persistent preferences ───────────────────────────────────────────────────
|
||||
let _prefs: Record<string, { ownerKey: string; charName: string }> = {};
|
||||
|
||||
function loadPrefs(): void {
|
||||
try { _prefs = JSON.parse(fs.readFileSync(PREFS_PATH, "utf8")); }
|
||||
catch { _prefs = {}; }
|
||||
}
|
||||
|
||||
function savePrefs(): void {
|
||||
try { fs.writeFileSync(PREFS_PATH, JSON.stringify(_prefs, null, 2)); }
|
||||
catch (err) { console.error("Failed to save sessionPreferences.json:", err); }
|
||||
}
|
||||
|
||||
loadPrefs();
|
||||
|
||||
export function setPersistentPreference(userKey: string, ownerKey: string, charName: string): void {
|
||||
_prefs[userKey] = { ownerKey, charName };
|
||||
savePrefs();
|
||||
}
|
||||
|
||||
export function clearPersistentPreference(userKey: string): void {
|
||||
delete _prefs[userKey];
|
||||
savePrefs();
|
||||
}
|
||||
|
||||
export function getPersistentPreference(userKey: string): { ownerKey: string; charName: string } | null {
|
||||
return _prefs[userKey] ?? null;
|
||||
}
|
||||
|
||||
// ─── Active borrow requests ───────────────────────────────────────────────────
|
||||
const pendingRequests: Map<string, BorrowRequest> = new Map();
|
||||
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 {
|
||||
|
|
@ -31,9 +60,7 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] {
|
|||
export function addPendingRequest(request: BorrowRequest): void {
|
||||
const key = requestKey(request.ownerKey, request.requesterKey);
|
||||
const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0;
|
||||
|
||||
pendingRequests.set(key, request);
|
||||
|
||||
if (expiry > 0) {
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.get(key)?.requestedAt === request.requestedAt) {
|
||||
|
|
@ -56,7 +83,7 @@ export function getDmMessage(ownerKey: string, requesterKey: string): { channelI
|
|||
return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null;
|
||||
}
|
||||
|
||||
// Session borrow management
|
||||
// ─── Session borrows ──────────────────────────────────────────────────────────
|
||||
export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void {
|
||||
sessionBorrows.set(requesterKey, { ownerKey, charName });
|
||||
}
|
||||
|
|
@ -70,22 +97,20 @@ export function clearSessionBorrows(): void {
|
|||
borrowDmMessages.clear();
|
||||
}
|
||||
|
||||
// Check if a user can use a character (owns it or has share/borrow access)
|
||||
export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean {
|
||||
if (requesterKey === ownerKey) return true;
|
||||
|
||||
// Check persistent share
|
||||
const char = getCharacterByName(ownerKey, charName);
|
||||
if (char?.sharedWith?.includes(requesterKey)) return true;
|
||||
|
||||
// Check session borrow
|
||||
const borrow = getSessionBorrow(requesterKey);
|
||||
if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Send borrow request DM to owner, fall back to poll channel ephemeral
|
||||
export function clearSessionBorrowForUser(userKey: string): void {
|
||||
sessionBorrows.delete(userKey);
|
||||
}
|
||||
|
||||
// ─── DM notifications ─────────────────────────────────────────────────────────
|
||||
export async function sendBorrowRequestDM(
|
||||
client: Client,
|
||||
ownerDiscordId: string,
|
||||
|
|
@ -117,7 +142,6 @@ export async function sendBorrowRequestDM(
|
|||
const msg = await dm.send({ content, components: [row] });
|
||||
storeDmMessage(ownerKey, requesterKey, dm.id, msg.id);
|
||||
} catch {
|
||||
// DM failed — fall back to poll channel ephemeral
|
||||
if (fallbackChannel) {
|
||||
await fallbackChannel.send({
|
||||
content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`,
|
||||
|
|
@ -126,7 +150,6 @@ export async function sendBorrowRequestDM(
|
|||
}
|
||||
}
|
||||
|
||||
// Update DM after accept/decline to disable buttons
|
||||
export async function updateBorrowDM(
|
||||
client: Client,
|
||||
ownerKey: string,
|
||||
|
|
@ -140,19 +163,30 @@ export async function updateBorrowDM(
|
|||
const message = await channel.messages.fetch(dm.messageId);
|
||||
const status = accepted ? "✅ Accepted" : "❌ Declined";
|
||||
await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] });
|
||||
} catch {
|
||||
// DM may have been deleted, ignore
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Returns the effective active character for a user — session borrow takes priority over own active char
|
||||
export function getEffectiveCharacter(usermapKey: string): { char: any; borrowedFrom: string | null } {
|
||||
const { getActiveCharacter, getCharacterByName } = require("./characters");
|
||||
const borrow = getSessionBorrow(usermapKey);
|
||||
// ─── Effective character resolution ──────────────────────────────────────────
|
||||
export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } {
|
||||
const { getActiveCharacter, getCharacterByName: getChar } = require("./characters");
|
||||
|
||||
// 1. Session borrow (temporary, resets on poll start)
|
||||
const borrow = getSessionBorrow(userKey);
|
||||
if (borrow) {
|
||||
const char = getCharacterByName(borrow.ownerKey, borrow.charName);
|
||||
const char = getChar(borrow.ownerKey, borrow.charName);
|
||||
if (char) return { char, borrowedFrom: borrow.ownerKey };
|
||||
}
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
|
||||
// 2. Persistent preference (survives restarts and poll resets)
|
||||
const pref = getPersistentPreference(userKey);
|
||||
console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`);
|
||||
if (pref) {
|
||||
const char = getChar(pref.ownerKey, pref.charName);
|
||||
if (char) return { char, borrowedFrom: pref.ownerKey };
|
||||
clearPersistentPreference(userKey);
|
||||
}
|
||||
|
||||
// 3. Own active character
|
||||
const char = getActiveCharacter(userKey);
|
||||
return { char: char ?? null, borrowedFrom: null };
|
||||
}
|
||||
100
src/systems/charSelect.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import {
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ActionRowBuilder,
|
||||
} from "discord.js";
|
||||
import { getCharacters, getCharacterByName } from "@systems/characters";
|
||||
import { getClassEmoji } from "@systems/emojis";
|
||||
import { format } from "@format";
|
||||
import { Character } from "@types";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const CHARS_PATH = path.join(__dirname, "../../data/characters.json");
|
||||
|
||||
export interface CharSelectOptions {
|
||||
customIdPrefix: string; // e.g. "switch_after_reclaim:flash"
|
||||
excludeCharName?: string; // exclude this char from the list
|
||||
appendToCustomId?: string; // appended after charName e.g. ":yes"
|
||||
pageSize?: number; // default 4
|
||||
page?: number; // default 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds paginated character selection button rows for a given user.
|
||||
* Includes own characters + shared characters.
|
||||
* Returns up to 2 rows: one for char buttons, one for pagination if needed.
|
||||
*/
|
||||
export function buildCharSelectButtons(
|
||||
userKey: string,
|
||||
options: CharSelectOptions
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const {
|
||||
customIdPrefix,
|
||||
excludeCharName,
|
||||
appendToCustomId = "",
|
||||
pageSize = 4,
|
||||
page = 0,
|
||||
} = options;
|
||||
|
||||
// Gather own + shared chars
|
||||
const ownChars = getCharacters(userKey);
|
||||
|
||||
const sharedChars: Character[] = [];
|
||||
try {
|
||||
const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8"));
|
||||
for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) {
|
||||
if (ownerKey === userKey) continue;
|
||||
for (const c of data.characters ?? []) {
|
||||
if (c.sharedWith?.includes(userKey)) sharedChars.push(c);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const allChars = [...ownChars, ...sharedChars]
|
||||
.filter((c) => c.name !== excludeCharName);
|
||||
|
||||
const pageChars = allChars.slice(page * pageSize, (page + 1) * pageSize);
|
||||
const hasNext = allChars.length > (page + 1) * pageSize;
|
||||
const hasPrev = page > 0;
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
// Char buttons
|
||||
if (pageChars.length > 0) {
|
||||
const btns = pageChars.map((c) => {
|
||||
const emojiStr = getClassEmoji(c.class);
|
||||
const emoji = format.emoji(emojiStr);
|
||||
const btn = new ButtonBuilder()
|
||||
.setCustomId(`${customIdPrefix}:${c.name}${appendToCustomId}`)
|
||||
.setStyle(ButtonStyle.Secondary)
|
||||
.setLabel(`${c.level} ${c.name}`);
|
||||
if (emoji) btn.setEmoji(emoji as any);
|
||||
return btn;
|
||||
});
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...btns));
|
||||
}
|
||||
|
||||
// Pagination row
|
||||
const navBtns: ButtonBuilder[] = [];
|
||||
if (hasPrev) {
|
||||
navBtns.push(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`${customIdPrefix}_page:${page - 1}`)
|
||||
.setLabel("← Prev")
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
}
|
||||
if (hasNext) {
|
||||
navBtns.push(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`${customIdPrefix}_page:${page + 1}`)
|
||||
.setLabel("Next →")
|
||||
.setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
}
|
||||
if (navBtns.length > 0) {
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navBtns));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
|
@ -23,52 +23,52 @@ function saveAccounts(): void {
|
|||
fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2));
|
||||
}
|
||||
|
||||
export function getCharacters(usermapKey: string): Character[] {
|
||||
return _chars[usermapKey]?.characters ?? [];
|
||||
export function getCharacters(userKey: string): Character[] {
|
||||
return _chars[userKey]?.characters ?? [];
|
||||
}
|
||||
|
||||
export function getActiveCharacter(usermapKey: string): Character | null {
|
||||
return getCharacters(usermapKey).find((c) => c.active) ?? null;
|
||||
export function getActiveCharacter(userKey: string): Character | null {
|
||||
return getCharacters(userKey).find((c) => c.active) ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterByName(usermapKey: string, name: string): Character | null {
|
||||
return getCharacters(usermapKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
|
||||
export function getCharacterByName(userKey: string, name: string): Character | null {
|
||||
return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterByClass(usermapKey: string, cls: ClassKey): Character | null {
|
||||
export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null {
|
||||
// Returns the active character of that class, or first found
|
||||
const chars = getCharacters(usermapKey).filter((c) => c.class === cls);
|
||||
const chars = getCharacters(userKey).filter((c) => c.class === cls);
|
||||
return chars.find((c) => c.active) ?? chars[0] ?? null;
|
||||
}
|
||||
|
||||
export function addCharacter(usermapKey: string, char: Omit<Character, "active">): boolean {
|
||||
if (!_chars[usermapKey]) _chars[usermapKey] = { characters: [] };
|
||||
const exists = _chars[usermapKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase());
|
||||
export function addCharacter(userKey: string, char: Omit<Character, "active">): boolean {
|
||||
if (!_chars[userKey]) _chars[userKey] = { characters: [] };
|
||||
const exists = _chars[userKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase());
|
||||
if (exists) return false;
|
||||
// If no active character, set this one as active
|
||||
const hasActive = _chars[usermapKey].characters.some((c) => c.active);
|
||||
_chars[usermapKey].characters.push({ ...char, active: !hasActive });
|
||||
const hasActive = _chars[userKey].characters.some((c) => c.active);
|
||||
_chars[userKey].characters.push({ ...char, active: !hasActive });
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeCharacter(usermapKey: string, name: string): boolean {
|
||||
if (!_chars[usermapKey]) return false;
|
||||
const before = _chars[usermapKey].characters.length;
|
||||
_chars[usermapKey].characters = _chars[usermapKey].characters.filter(
|
||||
export function removeCharacter(userKey: string, name: string): boolean {
|
||||
if (!_chars[userKey]) return false;
|
||||
const before = _chars[userKey].characters.length;
|
||||
_chars[userKey].characters = _chars[userKey].characters.filter(
|
||||
(c) => c.name.toLowerCase() !== name.toLowerCase()
|
||||
);
|
||||
if (_chars[usermapKey].characters.length === before) return false;
|
||||
if (_chars[userKey].characters.length === before) return false;
|
||||
// If we removed the active one, set the first remaining as active
|
||||
if (!_chars[usermapKey].characters.some((c) => c.active) && _chars[usermapKey].characters.length > 0) {
|
||||
_chars[usermapKey].characters[0].active = true;
|
||||
if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) {
|
||||
_chars[userKey].characters[0].active = true;
|
||||
}
|
||||
saveCharacters();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setActiveCharacter(usermapKey: string, name: string): boolean {
|
||||
const chars = _chars[usermapKey]?.characters;
|
||||
export function setActiveCharacter(userKey: string, name: string): boolean {
|
||||
const chars = _chars[userKey]?.characters;
|
||||
if (!chars) return false;
|
||||
const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
||||
if (!target) return false;
|
||||
|
|
@ -78,8 +78,8 @@ export function setActiveCharacter(usermapKey: string, name: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
export function setCharacterNation(usermapKey: string, name: string, nation: Nation): boolean {
|
||||
const char = getCharacterByName(usermapKey, name);
|
||||
export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean {
|
||||
const char = getCharacterByName(userKey, name);
|
||||
if (!char) return false;
|
||||
char.nation = nation;
|
||||
saveCharacters();
|
||||
|
|
@ -87,11 +87,11 @@ export function setCharacterNation(usermapKey: string, name: string, nation: Nat
|
|||
}
|
||||
|
||||
export function setCharacterStats(
|
||||
usermapKey: string,
|
||||
userKey: string,
|
||||
name: string,
|
||||
stats: { atk?: number; def?: number; heal?: number }
|
||||
): boolean {
|
||||
const char = getCharacterByName(usermapKey, name);
|
||||
const char = getCharacterByName(userKey, name);
|
||||
if (!char) return false;
|
||||
if (!char.stats) char.stats = {};
|
||||
Object.assign(char.stats, stats);
|
||||
|
|
@ -100,12 +100,12 @@ export function setCharacterStats(
|
|||
}
|
||||
|
||||
// ─── Account data ─────────────────────────────────────────────────────────────
|
||||
export function getAccountData(usermapKey: string): AccountData {
|
||||
return _accounts[usermapKey] ?? {};
|
||||
export function getAccountData(userKey: string): AccountData {
|
||||
return _accounts[userKey] ?? {};
|
||||
}
|
||||
|
||||
export function setAccountData(usermapKey: string, data: Partial<AccountData>): void {
|
||||
if (!_accounts[usermapKey]) _accounts[usermapKey] = {};
|
||||
Object.assign(_accounts[usermapKey], data);
|
||||
export function setAccountData(userKey: string, data: Partial<AccountData>): void {
|
||||
if (!_accounts[userKey]) _accounts[userKey] = {};
|
||||
Object.assign(_accounts[userKey], data);
|
||||
saveAccounts();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function getDefaults(): Required<BotConfig> {
|
|||
showNationTotalsInHeader: false,
|
||||
showNoInNationField: false,
|
||||
borrowRequestExpiryMs: 0, // 0 = never expire
|
||||
conflictReclaimBehavior: "revert",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
309
src/systems/conflict.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import {
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
ActionRowBuilder,
|
||||
EmbedBuilder,
|
||||
ButtonInteraction,
|
||||
TextChannel,
|
||||
} from "discord.js";
|
||||
import { cfg } from "@systems/config";
|
||||
import { getCharacters, setActiveCharacter } from "@systems/characters";
|
||||
import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow";
|
||||
import { getImpersonation } from "@systems/impersonate";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
import { resolveMessage, nowFormatted } from "@systems/messages";
|
||||
import { getClassEmoji } from "@systems/emojis";
|
||||
import { format } from "@systems/format";
|
||||
import { Character } from "@types";
|
||||
import { buildCharSelectButtons } from "@systems/charSelect";
|
||||
|
||||
|
||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||
const RECLAIM_STYLE = ButtonStyle.Secondary;
|
||||
const SWITCH_STYLE = ButtonStyle.Secondary;
|
||||
const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false";
|
||||
const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false";
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
const pendingConflicts = new Map<string, {
|
||||
ownerKey: string;
|
||||
borrowerKey: string;
|
||||
charName: string;
|
||||
ownerId: string;
|
||||
page: number;
|
||||
}>();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder {
|
||||
const emojiStr = getClassEmoji(char.class);
|
||||
const emoji = format.emoji(emojiStr);
|
||||
btn.setLabel(`${char.level} ${char.name}`);
|
||||
if (emoji) btn.setEmoji(emoji as any);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function buildConflictEmbed(borrowerKey: string, char: Character): EmbedBuilder {
|
||||
return new EmbedBuilder()
|
||||
.setTitle("⚠️ Character Conflict")
|
||||
.setDescription(
|
||||
`**${format.char(char)}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.`
|
||||
)
|
||||
.setColor(0xe8a317);
|
||||
}
|
||||
|
||||
function buildConflictButtons(
|
||||
ownerKey: string,
|
||||
borrowerKey: string,
|
||||
borrowedCharName: string,
|
||||
ownerId: string,
|
||||
allChars: Character[],
|
||||
page: number
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
const PAGE_SIZE = 4;
|
||||
const otherChars = allChars.filter((c) => c.name !== borrowedCharName);
|
||||
const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const hasMore = otherChars.length > (page + 1) * PAGE_SIZE;
|
||||
const hasPrev = page > 0;
|
||||
const rows: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
// Row 1: Reclaim button
|
||||
const reclaimId = `conflict_reclaim:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}`;
|
||||
pendingConflicts.set(reclaimId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page });
|
||||
const borrowed = allChars.find((c) => c.name === borrowedCharName);
|
||||
const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE);
|
||||
if (borrowed) {
|
||||
reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`);
|
||||
const emojiStr = getClassEmoji(borrowed.class);
|
||||
const emoji = format.emoji(emojiStr);
|
||||
if (emoji) reclaimBtn.setEmoji(emoji as any);
|
||||
} else {
|
||||
reclaimBtn.setLabel(`Reclaim ${borrowedCharName}`);
|
||||
}
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(reclaimBtn));
|
||||
|
||||
// Row 2: Switch buttons
|
||||
const charButtons = pageChars.map((char) => {
|
||||
const id = `conflict_switch:${ownerKey}:${borrowerKey}:${char.name}:${ownerId}`;
|
||||
pendingConflicts.set(id, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page });
|
||||
return applyCharToButton(new ButtonBuilder().setCustomId(id).setStyle(SWITCH_STYLE), char);
|
||||
});
|
||||
if (charButtons.length > 0) {
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...charButtons));
|
||||
}
|
||||
|
||||
// Row 3: Pagination
|
||||
const navButtons: ButtonBuilder[] = [];
|
||||
if (hasPrev) {
|
||||
const prevId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page - 1}`;
|
||||
pendingConflicts.set(prevId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page - 1 });
|
||||
navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary));
|
||||
}
|
||||
if (hasMore) {
|
||||
const nextId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page + 1}`;
|
||||
pendingConflicts.set(nextId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page + 1 });
|
||||
navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary));
|
||||
}
|
||||
if (navButtons.length > 0) {
|
||||
rows.push(new ActionRowBuilder<ButtonBuilder>().addComponents(...navButtons));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
export async function showConflictEmbed(
|
||||
interaction: ButtonInteraction,
|
||||
ownerKey: string,
|
||||
borrowerKey: string,
|
||||
borrowedChar: Character,
|
||||
allOwnerChars: Character[]
|
||||
): Promise<void> {
|
||||
const embed = buildConflictEmbed(borrowerKey, borrowedChar);
|
||||
const buttons = buildConflictButtons(ownerKey, borrowerKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0);
|
||||
await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true });
|
||||
}
|
||||
|
||||
export async function handleConflictButton(interaction: ButtonInteraction): Promise<void> {
|
||||
console.log("[conflict] button received:", interaction.customId);
|
||||
const { customId } = interaction;
|
||||
|
||||
// ── Pagination ──────────────────────────────────────────────────────────────
|
||||
if (customId.startsWith("conflict_page:")) {
|
||||
const parts = customId.split(":");
|
||||
const ownerKey = parts[1];
|
||||
const borrowerKey = parts[2];
|
||||
const charName = parts[3];
|
||||
const ownerId = parts[4];
|
||||
const page = parseInt(parts[5]);
|
||||
|
||||
const allChars = getCharacters(ownerKey);
|
||||
const borrowed = allChars.find((c) => c.name === charName);
|
||||
if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true });
|
||||
|
||||
const embed = buildConflictEmbed(borrowerKey, borrowed);
|
||||
const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page);
|
||||
await interaction.update({ embeds: [embed], components: buttons });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Switch to another char ──────────────────────────────────────────────────
|
||||
if (customId.startsWith("conflict_switch:")) {
|
||||
const parts = customId.split(":");
|
||||
const ownerKey = parts[1];
|
||||
const borrowerKey = parts[2];
|
||||
const newCharName = parts[3];
|
||||
const ownerId = parts[4];
|
||||
|
||||
setActiveCharacter(ownerKey, newCharName);
|
||||
clearSessionBorrowForUser(ownerKey);
|
||||
|
||||
const impersonating = getImpersonation(ownerId);
|
||||
const voteId = impersonating ? `impersonated:${impersonating}` : ownerId;
|
||||
const slot = [...polls.keys()][0];
|
||||
const state = slot !== undefined ? polls.get(slot) : null;
|
||||
|
||||
if (state && AUTO_VOTE_ON_SWITCH) {
|
||||
const guild = interaction.guild!;
|
||||
const member = await guild.members.fetch(ownerId);
|
||||
const { char } = getEffectiveCharacter(ownerKey);
|
||||
const now = nowFormatted();
|
||||
const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
|
||||
|
||||
state.yes.set(voteId, {
|
||||
userKey: ownerKey,
|
||||
displayName: member.nickname ?? member.user.globalName ?? member.user.username,
|
||||
characterName: char?.name,
|
||||
characterClass: char?.class,
|
||||
characterLevel: char?.level,
|
||||
characterNation: char?.nation,
|
||||
votedAt: now,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
});
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot!);
|
||||
}
|
||||
|
||||
const newChar = getCharacters(ownerKey).find((c) => c.name === newCharName);
|
||||
const charDisplay = newChar ? format.char(newChar) : newCharName;
|
||||
|
||||
await interaction.update({
|
||||
embeds: [new EmbedBuilder()
|
||||
.setTitle("🔄 Switched")
|
||||
.setDescription(`${charDisplay}${AUTO_VOTE_ON_SWITCH ? " — voted Yes." : ""}`)
|
||||
.setColor(0x57f287)],
|
||||
components: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Reclaim ─────────────────────────────────────────────────────────────────
|
||||
if (customId.startsWith("conflict_reclaim:")) {
|
||||
console.log("[reclaim] handler triggered");
|
||||
const parts = customId.split(":");
|
||||
const ownerKey = parts[1];
|
||||
const borrowerKey = parts[2];
|
||||
const charName = parts[3];
|
||||
const ownerId = parts[4];
|
||||
|
||||
const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert";
|
||||
const slot = [...polls.keys()][0];
|
||||
const state = slot !== undefined ? polls.get(slot) : null;
|
||||
|
||||
let borrowerDiscordId: string | undefined;
|
||||
let borrowerVoteType: "yes" | "no" = "yes"; // default to yes
|
||||
|
||||
if (state) {
|
||||
for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) {
|
||||
const isYes = state.yes.has(id);
|
||||
if (entry.userKey === borrowerKey) {
|
||||
borrowerVoteType = isYes ? "yes" : "no";
|
||||
// Capture borrower's Discord ID for notification
|
||||
borrowerDiscordId = (entry as any).discordId;
|
||||
|
||||
if (reclaimBehavior === "remove") {
|
||||
state.yes.delete(id);
|
||||
state.no.delete(id);
|
||||
} else {
|
||||
// Clear borrow so getEffectiveCharacter returns own char
|
||||
clearSessionBorrowForUser(borrowerKey);
|
||||
clearPersistentPreference(borrowerKey);
|
||||
const { char: ownChar } = getEffectiveCharacter(borrowerKey);
|
||||
if (ownChar) {
|
||||
entry.characterName = ownChar.name;
|
||||
entry.characterClass = ownChar.class;
|
||||
entry.characterLevel = ownChar.level;
|
||||
entry.characterNation = ownChar.nation;
|
||||
entry.borrowedFrom = undefined;
|
||||
} else {
|
||||
state.yes.delete(id);
|
||||
state.no.delete(id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Owner joins with their reclaimed char
|
||||
const guild = interaction.guild!;
|
||||
const member = await guild.members.fetch(ownerId);
|
||||
const impersonating = getImpersonation(ownerId);
|
||||
const voteId = impersonating ? `impersonated:${impersonating}` : ownerId;
|
||||
|
||||
setActiveCharacter(ownerKey, charName);
|
||||
clearSessionBorrowForUser(ownerKey);
|
||||
const { char } = getEffectiveCharacter(ownerKey);
|
||||
const now = nowFormatted();
|
||||
const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null);
|
||||
|
||||
state.yes.set(voteId, {
|
||||
userKey: ownerKey,
|
||||
displayName: member.nickname ?? member.user.globalName ?? member.user.username,
|
||||
characterName: char?.name,
|
||||
characterClass: char?.class,
|
||||
characterLevel: char?.level,
|
||||
characterNation: char?.nation,
|
||||
votedAt: now,
|
||||
publicMessage: publicMsg ?? undefined,
|
||||
});
|
||||
|
||||
const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot!);
|
||||
|
||||
console.log("[reclaim notify] borrowerDiscordId:", borrowerDiscordId, "notify:", RECLAIM_NOTIFY_BORROWER);
|
||||
|
||||
// Notify borrower if enabled and we have their Discord ID
|
||||
if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) {
|
||||
try {
|
||||
const borrowerMember = await guild.members.fetch(borrowerDiscordId);
|
||||
|
||||
const btns = buildCharSelectButtons(borrowerKey, {
|
||||
customIdPrefix: `switch_after_reclaim:${borrowerKey}`,
|
||||
excludeCharName: charName,
|
||||
appendToCustomId: `:${borrowerVoteType}`,
|
||||
});
|
||||
console.log("[reclaim notify] btns length:", btns.length);
|
||||
console.log("[reclaim notify] btns:", JSON.stringify(btns.map(r => r.toJSON())));
|
||||
await borrowerMember.send({
|
||||
content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. Pick another character:`,
|
||||
components: btns.length > 0 ? btns : [],
|
||||
});
|
||||
} catch {
|
||||
// DM may be disabled — silently ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const borrowed = getCharacters(ownerKey).find((c) => c.name === charName);
|
||||
const charDisplay = borrowed ? format.char(borrowed) : charName;
|
||||
|
||||
await interaction.update({
|
||||
embeds: [new EmbedBuilder()
|
||||
.setTitle("↩️ Reclaimed")
|
||||
.setDescription(`${charDisplay} reclaimed from **${borrowerKey}**.`)
|
||||
.setColor(0x57f287)],
|
||||
components: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
121
src/systems/format.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { ClassKey, Nation, WRankEntry } from "@src/types";
|
||||
import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis";
|
||||
|
||||
// ─── Individual formatters ────────────────────────────────────────────────────
|
||||
|
||||
export interface CharDisplayOptions {
|
||||
emoji?: boolean; // show class emoji (default: true)
|
||||
level?: boolean; // show level (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a character for display in embeds and messages.
|
||||
* Output: <:class:> 79 «Flash»
|
||||
*/
|
||||
function char(
|
||||
c: { class: ClassKey; level: number; name: string },
|
||||
options?: CharDisplayOptions
|
||||
): string {
|
||||
const showEmoji = options?.emoji ?? true;
|
||||
const showLevel = options?.level ?? true;
|
||||
const classStr = showEmoji ? (getClassEmoji(c.class) || c.class) : c.class;
|
||||
const levelStr = showLevel ? `${c.level} ` : "";
|
||||
return `${classStr} ${levelStr}${c.name}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a nation name with its emoji.
|
||||
* Output: <:capella:> Capella
|
||||
*/
|
||||
function nation(n: Nation): string {
|
||||
const emoji = getNationEmoji(n);
|
||||
return emoji ? `${emoji} ${n}` : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a score line.
|
||||
* Output: <:score:> 3000 <:kd:> 32/18
|
||||
*/
|
||||
function score(pts: number, k?: number, d?: number): string {
|
||||
const scoreEmoji = getEmoji("score") || "📊";
|
||||
const kdEmoji = getEmoji("kd") || "⚔️";
|
||||
const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : "";
|
||||
return `${scoreEmoji} ${pts}${kdStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Discord custom emoji string to an object for ButtonBuilder.setEmoji()
|
||||
* Input: "<:fb:1511020923510194428>"
|
||||
* Output: { name: "fb", id: "1511020923510194428" }
|
||||
*/
|
||||
function emoji(emojiStr: string): { name: string; id: string } | string | null {
|
||||
if (!emojiStr) return null;
|
||||
const match = emojiStr.match(/^<:(\w+):(\d+)>$/);
|
||||
if (match) return { name: match[1], id: match[2] };
|
||||
return emojiStr;
|
||||
}
|
||||
|
||||
// ─── W.Rank formatters ────────────────────────────────────────────────────────
|
||||
|
||||
export interface WRankDisplayOptions {
|
||||
goal: number;
|
||||
brackets?: boolean; // wrap delta in parentheses (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the rank indicator for a wrank entry.
|
||||
* Output: <:wrank_1_gold:> or <:wrank_1:> or bold/plain number fallback
|
||||
*/
|
||||
function wrankRank(entry: WRankEntry, goal: number): string {
|
||||
const isDone = entry.tgCount >= goal;
|
||||
const rankKey = isDone ? `wrank_${entry.currentRank}_gold` : `wrank_${entry.currentRank}`;
|
||||
return getEmoji(rankKey) || (isDone ? `**${entry.currentRank}**` : `${entry.currentRank}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the delta indicator for a wrank entry.
|
||||
* Output: <:wrank_up:><:wrank_up_2:> or ↑2, empty string if no change
|
||||
*/
|
||||
function wrankDelta(entry: WRankEntry, options?: { brackets?: boolean }): string {
|
||||
const brackets = options?.brackets ?? true;
|
||||
const prev = entry.previousRank;
|
||||
const delta = prev !== undefined ? entry.currentRank - prev : 0;
|
||||
|
||||
if (delta === 0 && prev === undefined) return "";
|
||||
|
||||
let inner: string;
|
||||
if (delta < 0) {
|
||||
const abs = Math.abs(delta);
|
||||
const numEmoji = getEmoji(`wrank_up_${abs}`);
|
||||
inner = (getEmoji("wrank_up") || "↑") + (numEmoji || abs);
|
||||
} else if (delta > 0) {
|
||||
const numEmoji = getEmoji(`wrank_down_${delta}`);
|
||||
inner = (getEmoji("wrank_down") || "↓") + (numEmoji || delta);
|
||||
} else {
|
||||
inner = (getEmoji("wrank_neutral") || "·") + "0";
|
||||
}
|
||||
|
||||
return brackets ? ` (${inner})` : ` ${inner}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a full wrank display string: rank + delta.
|
||||
* Output: <:wrank_1_gold:> (<:wrank_up:><:wrank_up_2:>)
|
||||
*/
|
||||
function wrankFull(entry: WRankEntry, options: WRankDisplayOptions): string {
|
||||
return wrankRank(entry, options.goal) + wrankDelta(entry, { brackets: options.brackets });
|
||||
}
|
||||
|
||||
// ─── Namespace export ─────────────────────────────────────────────────────────
|
||||
|
||||
export const format = {
|
||||
char,
|
||||
nation,
|
||||
score,
|
||||
emoji,
|
||||
wrank: {
|
||||
rank: wrankRank,
|
||||
delta: wrankDelta,
|
||||
full: wrankFull,
|
||||
},
|
||||
};
|
||||
|
|
@ -42,7 +42,7 @@ export function upsertScore(score: TGScore): void {
|
|||
|
||||
// Overwrite existing score for this player+slot
|
||||
result.scores = result.scores.filter(
|
||||
(s) => !(s.usermapKey === score.usermapKey && s.slot === score.slot && s.date === score.date)
|
||||
(s) => !(s.userKey === score.userKey && s.slot === score.slot && s.date === score.date)
|
||||
);
|
||||
result.scores.push(score);
|
||||
saveResult(result);
|
||||
|
|
|
|||
44
src/systems/impersonate.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false";
|
||||
const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false";
|
||||
|
||||
// realDiscordId → userKey being impersonated
|
||||
const impersonations = new Map<string, string>();
|
||||
|
||||
export function setImpersonation(realDiscordId: string, userKey: string): void {
|
||||
impersonations.set(realDiscordId, userKey);
|
||||
}
|
||||
|
||||
export function clearImpersonation(realDiscordId: string): void {
|
||||
impersonations.delete(realDiscordId);
|
||||
}
|
||||
|
||||
export function getImpersonation(realDiscordId: string): string | null {
|
||||
return impersonations.get(realDiscordId) ?? null;
|
||||
}
|
||||
|
||||
export function clearAllImpersonations(): void {
|
||||
if (IMPERSONATE_RESET_ON_POLL) impersonations.clear();
|
||||
}
|
||||
|
||||
export function shouldShowIndicator(): boolean {
|
||||
return IMPERSONATE_INDICATOR;
|
||||
}
|
||||
|
||||
// Returns all registered userKeys from usermap.json
|
||||
export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] {
|
||||
try {
|
||||
const usermap = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8")
|
||||
);
|
||||
return Object.entries(usermap).map(([, entry]: [string, any]) => {
|
||||
const fileKey = typeof entry === "string" ? entry : entry.file;
|
||||
const aliases = typeof entry === "object" ? (entry.aliases ?? []) : [];
|
||||
return { userKey: fileKey, aliases };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@ import { Nation } from "../types";
|
|||
import { getActiveCharacter } from "./characters";
|
||||
|
||||
// Resolve a user's nation — character nation takes priority over Discord role
|
||||
export function resolveNation(member: GuildMember, usermapKey: string | null): Nation | null {
|
||||
export function resolveNation(member: GuildMember, userKey: string | null): Nation | null {
|
||||
// 1. Active character's nation
|
||||
if (usermapKey) {
|
||||
const char = getActiveCharacter(usermapKey);
|
||||
if (userKey) {
|
||||
const char = getActiveCharacter(userKey);
|
||||
if (char) return char.nation;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,17 @@ import {
|
|||
TextChannel,
|
||||
GuildMember,
|
||||
} from "discord.js";
|
||||
import { PollState, VoteEntry, Nation, TGSlot } from "../types";
|
||||
import { cfg } from "./config";
|
||||
import { getEmoji, getClassEmoji, getNationEmoji } from "./emojis";
|
||||
import { getActiveCharacter, getCharacterByName } from "./characters";
|
||||
import { resolveNation } from "./nations";
|
||||
import { getEntry, getBringer } from "./wrank";
|
||||
import { nowFormatted } from "./messages";
|
||||
import { PollState, VoteEntry, Nation, TGSlot } from "@src/types";
|
||||
import { cfg } from "@systems/config";
|
||||
import { getEmoji, getClassEmoji, getNationEmoji } from "@systems/emojis";
|
||||
import { getActiveCharacter, getCharacterByName } from "@systems/characters";
|
||||
import { resolveNation } from "@systems/nations";
|
||||
import { getEntry, getBringer } from "@systems/wrank";
|
||||
import { nowFormatted } from "@systems/messages";
|
||||
import { format } from "@format";
|
||||
import { persist } from "@systems/pollPersistence"
|
||||
import { clearSessionBorrows } from "@systems/borrow";
|
||||
import { clearAllImpersonations } from "@systems/impersonate";
|
||||
|
||||
// ─── Poll state ───────────────────────────────────────────────────────────────
|
||||
export const polls: Map<number, PollState> = new Map();
|
||||
|
|
@ -51,30 +55,32 @@ export function resetPollOverrides(): void {
|
|||
ephemeralOverrides.clear();
|
||||
}
|
||||
|
||||
export function lockPoll(slot: number): void {
|
||||
const state = polls.get(slot);
|
||||
if (!state) return;
|
||||
state.locked = true;
|
||||
|
||||
// Snapshot the userKeys that were in yes at lock time
|
||||
state.lockedYesKeys = new Set(
|
||||
[...state.yes.values()]
|
||||
.map((e) => e.userKey)
|
||||
.filter((k): k is string => !!k)
|
||||
);
|
||||
|
||||
persist.save(polls)
|
||||
}
|
||||
|
||||
|
||||
// ─── Character display ────────────────────────────────────────────────────────
|
||||
function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
||||
const format = cfg("charDisplayFormat");
|
||||
const cfgFormat = cfg("charDisplayFormat");
|
||||
const nation = entry.characterNation;
|
||||
const wRankEntry = entry.usermapKey ? getEntry(entry.usermapKey, nation ?? "Capella") : null;
|
||||
const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null;
|
||||
|
||||
let wrank = "";
|
||||
if (wRankEntry) {
|
||||
const goal = cfg("wRankGoal");
|
||||
const isDone = wRankEntry.tgCount >= goal;
|
||||
const rank = wRankEntry.currentRank;
|
||||
const prev = wRankEntry.previousRank;
|
||||
const delta = prev !== undefined ? rank - prev : 0;
|
||||
// W.Rank emoji with text fallback
|
||||
const rankEmojiKey = isDone ? `wrank_${rank}_gold` : `wrank_${rank}`;
|
||||
const rankStr = getEmoji(rankEmojiKey) || (isDone ? `🟡${rank}` : `${rank}`);
|
||||
|
||||
// Delta arrows with text fallback
|
||||
let deltaStr = "";
|
||||
if (delta < 0) deltaStr = ` (${getEmoji("wrank_up") || "↑"}${Math.abs(delta)})`;
|
||||
else if (delta > 0) deltaStr = ` (${getEmoji("wrank_down") || "↓"}${delta})`;
|
||||
else if (prev !== undefined) deltaStr = ` (${getEmoji("wrank_neutral") || "·"}0)`;
|
||||
|
||||
wrank = `${rankStr}${deltaStr}`;
|
||||
const wRankGoal = cfg("wRankGoal");
|
||||
wrank = format.wrank.full(wRankEntry, { goal: wRankGoal, brackets: true });
|
||||
}
|
||||
|
||||
const classStr = entry.characterClass
|
||||
|
|
@ -85,7 +91,7 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
|||
? `${entry.characterLevel}`
|
||||
: "";
|
||||
|
||||
let row = format
|
||||
let row = cfgFormat
|
||||
.replace("{wrank}", wrank)
|
||||
.replace("{class}", classStr)
|
||||
.replace("{level}", levelStr)
|
||||
|
|
@ -94,15 +100,33 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string {
|
|||
.trim();
|
||||
|
||||
// Bringer title — independent of W.Rank so override always shows
|
||||
if (nation && entry.usermapKey) {
|
||||
function getNationBringerTitle(nation: Nation) {
|
||||
const stormBringerIcon = getEmoji("storm_bringer") || "⚡";
|
||||
const stormBringer = `${stormBringerIcon}`;
|
||||
|
||||
const luminousBringerIcon = getEmoji("luminous_bringer") || "⚡";
|
||||
const luminousBringer = `${luminousBringerIcon}` || `⚡ Luminous Bringer`;
|
||||
|
||||
const nationMap = {
|
||||
"Capella": luminousBringer,
|
||||
"Procyon": stormBringer
|
||||
};
|
||||
|
||||
return nationMap[nation];
|
||||
}
|
||||
if (nation && entry.userKey) {
|
||||
const bringer = getBringer(nation);
|
||||
if (bringer && bringer === entry.usermapKey) {
|
||||
const emoji = nation === "Capella"
|
||||
? (getEmoji("luminous_bringer") || "🔆")
|
||||
: (getEmoji("storm_bringer") || "⚡");
|
||||
const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
|
||||
row += ` · ${emoji} **${title}**`;
|
||||
if (bringer && bringer === entry.characterName) {
|
||||
const bringerTitle = getNationBringerTitle(nation);
|
||||
row += ` · ${bringerTitle}`;
|
||||
}
|
||||
// if (bringer && bringer === entry.characterName) {
|
||||
// const emoji = nation === "Capella"
|
||||
// ? (getEmoji("luminous_bringer") || "🔆")
|
||||
// : (getEmoji("storm_bringer") || "⚡");
|
||||
// const title = nation === "Capella" ? "Luminous Bringer" : "Storm Bringer";
|
||||
// row += ` · ${emoji} **${title}**`;
|
||||
// }
|
||||
}
|
||||
|
||||
if (entry.borrowedFrom) {
|
||||
|
|
@ -203,21 +227,41 @@ export function buildEmbed(state: PollState, overrideLockMsg?: string): EmbedBui
|
|||
return embed;
|
||||
}
|
||||
|
||||
export function buildButtons(disabled: boolean): ActionRowBuilder<ButtonBuilder> {
|
||||
export function buildButtons(
|
||||
disabled: boolean,
|
||||
showSubmit?: boolean
|
||||
): ActionRowBuilder<ButtonBuilder>[] {
|
||||
if (showSubmit) {
|
||||
const scoreEmoji = getEmoji("score");
|
||||
const submitBtn = new ButtonBuilder()
|
||||
.setCustomId("tg_score_submit")
|
||||
.setLabel("Submit Score")
|
||||
.setStyle(ButtonStyle.Secondary);
|
||||
if (scoreEmoji) submitBtn.setEmoji(format.emoji(scoreEmoji) ?? scoreEmoji);
|
||||
return [new ActionRowBuilder<ButtonBuilder>().addComponents(submitBtn)];
|
||||
}
|
||||
|
||||
const yesBtn = new ButtonBuilder()
|
||||
.setCustomId("tg_yes").setLabel("✅ Yes").setStyle(ButtonStyle.Success).setDisabled(disabled);
|
||||
const noBtn = new ButtonBuilder()
|
||||
.setCustomId("tg_no").setLabel("❌ No").setStyle(ButtonStyle.Danger).setDisabled(disabled);
|
||||
return new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn);
|
||||
return [new ActionRowBuilder<ButtonBuilder>().addComponents(yesBtn, noBtn)];
|
||||
}
|
||||
|
||||
export async function updatePollMessage(channel: TextChannel, slot: number, overrideLockMsg?: string): Promise<void> {
|
||||
export async function updatePollMessage(
|
||||
channel: TextChannel,
|
||||
slot: number,
|
||||
overrideLockMsg?: string,
|
||||
showSubmit?: boolean
|
||||
): Promise<void> {
|
||||
const state = polls.get(slot);
|
||||
if (!state?.messageId) return;
|
||||
console.log(`[updatePollMessage] slot=${slot} showSubmit=${showSubmit} messageId=${state.messageId}`);
|
||||
const buttons = buildButtons(state.locked || state.confirmed !== null, showSubmit);
|
||||
console.log(`[updatePollMessage] components rows=${buttons.length}`);
|
||||
try {
|
||||
const msg = await channel.messages.fetch(state.messageId);
|
||||
const disabled = state.locked || state.confirmed !== null;
|
||||
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: [buildButtons(disabled)] });
|
||||
const msg = await channel.messages.fetch(state.messageId);
|
||||
await msg.edit({ embeds: [buildEmbed(state, overrideLockMsg)], components: buttons });
|
||||
} catch (err) {
|
||||
console.error("Failed to update poll message:", err);
|
||||
}
|
||||
|
|
@ -225,8 +269,10 @@ export async function updatePollMessage(channel: TextChannel, slot: number, over
|
|||
|
||||
export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void> {
|
||||
resetPollOverrides();
|
||||
const { clearSessionBorrows } = require("./borrow");
|
||||
persist.clear();
|
||||
|
||||
clearSessionBorrows();
|
||||
clearAllImpersonations();
|
||||
|
||||
const state: PollState = {
|
||||
messageId: null, slot: slot.tgHour,
|
||||
|
|
@ -234,33 +280,36 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise<void
|
|||
locked: false, confirmed: null,
|
||||
};
|
||||
polls.set(slot.tgHour, state);
|
||||
const msg = await channel.send({ embeds: [buildEmbed(state)], components: [buildButtons(false)] });
|
||||
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.`);
|
||||
|
||||
persist.save(polls)
|
||||
}
|
||||
|
||||
export function createVoteEntry(
|
||||
userId: string,
|
||||
member: GuildMember,
|
||||
usermapKey: string | null,
|
||||
userKey: 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)
|
||||
const { getEffectiveCharacter } = require("@systems/borrow");
|
||||
const { char, borrowedFrom: bf } = userKey
|
||||
? getEffectiveCharacter(userKey)
|
||||
: { char: null, borrowedFrom: null };
|
||||
console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`);
|
||||
|
||||
return {
|
||||
usermapKey: usermapKey ?? (undefined as any),
|
||||
userKey: userKey ?? (undefined as any),
|
||||
displayName,
|
||||
characterName: char?.name,
|
||||
characterClass: char?.class,
|
||||
characterLevel: char?.level,
|
||||
characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined),
|
||||
characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined),
|
||||
borrowedFrom: bf ?? undefined,
|
||||
};
|
||||
}
|
||||
88
src/systems/pollPersistence.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { PollState, VoteEntry } from "@src/types";
|
||||
|
||||
const PERSIST_PATH = path.join(__dirname, "../../data/poll-state.json");
|
||||
|
||||
// ─── Serialized shape ─────────────────────────────────────────────────────────
|
||||
// Maps → arrays of [key, value] tuples, Sets → arrays of values
|
||||
|
||||
interface SerializedPollState {
|
||||
messageId: string | null;
|
||||
slot: number;
|
||||
yes: [string, VoteEntry][];
|
||||
no: [string, VoteEntry][];
|
||||
locked: boolean;
|
||||
confirmed: "yes" | "no" | null;
|
||||
lockMessage?: string;
|
||||
confirmMessage?: string;
|
||||
lockedYesKeys?: string[];
|
||||
}
|
||||
|
||||
// ─── Serialize / deserialize ──────────────────────────────────────────────────
|
||||
|
||||
function serialize(polls: Map<number, PollState>): SerializedPollState[] {
|
||||
return [...polls.values()].map((s) => ({
|
||||
messageId: s.messageId,
|
||||
slot: s.slot,
|
||||
yes: [...s.yes.entries()],
|
||||
no: [...s.no.entries()],
|
||||
locked: s.locked,
|
||||
confirmed: s.confirmed,
|
||||
lockMessage: s.lockMessage,
|
||||
confirmMessage: s.confirmMessage,
|
||||
lockedYesKeys: s.lockedYesKeys ? [...s.lockedYesKeys] : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function deserialize(data: SerializedPollState[]): Map<number, PollState> {
|
||||
const polls = new Map<number, PollState>();
|
||||
for (const s of data) {
|
||||
polls.set(s.slot, {
|
||||
messageId: s.messageId,
|
||||
slot: s.slot,
|
||||
yes: new Map(s.yes),
|
||||
no: new Map(s.no),
|
||||
locked: s.locked,
|
||||
confirmed: s.confirmed,
|
||||
lockMessage: s.lockMessage,
|
||||
confirmMessage: s.confirmMessage,
|
||||
lockedYesKeys: s.lockedYesKeys ? new Set(s.lockedYesKeys) : undefined,
|
||||
});
|
||||
}
|
||||
return polls;
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
export namespace persist {
|
||||
export function save(polls: Map<number, PollState>): void {
|
||||
try {
|
||||
fs.writeFileSync(PERSIST_PATH, JSON.stringify(serialize(polls), null, 2), "utf8");
|
||||
} catch (err) {
|
||||
console.error("[pollPersistence] Failed to save poll state:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export function load(): Map<number, PollState> | null {
|
||||
try {
|
||||
if (!fs.existsSync(PERSIST_PATH)) return null;
|
||||
const raw = fs.readFileSync(PERSIST_PATH, "utf8");
|
||||
const data = JSON.parse(raw) as SerializedPollState[];
|
||||
const polls = deserialize(data);
|
||||
console.log(`[pollPersistence] Restored ${polls.size} poll(s) from disk.`);
|
||||
return polls;
|
||||
} catch (err) {
|
||||
console.error("[pollPersistence] Failed to load poll state:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(): void {
|
||||
try {
|
||||
if (fs.existsSync(PERSIST_PATH)) fs.unlinkSync(PERSIST_PATH);
|
||||
} catch (err) {
|
||||
console.error("[pollPersistence] Failed to clear poll state:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -53,11 +53,14 @@ export function detectSlot(): number | null {
|
|||
}
|
||||
|
||||
export interface ScoreSubmission {
|
||||
usermapKey: string;
|
||||
userKey: string; // owner's key (score goes here)
|
||||
playedBy?: string; // borrower's key if different from owner
|
||||
characterName: string;
|
||||
cls: ClassKey;
|
||||
nation: Nation;
|
||||
pts: number;
|
||||
k?: number;
|
||||
d?: number;
|
||||
slot: number;
|
||||
date?: string;
|
||||
atk?: number;
|
||||
|
|
@ -71,11 +74,14 @@ export function submitScore(sub: ScoreSubmission): void {
|
|||
const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`;
|
||||
|
||||
const score: TGScore = {
|
||||
usermapKey: sub.usermapKey,
|
||||
userKey: sub.userKey,
|
||||
playedBy: sub.playedBy,
|
||||
characterName: sub.characterName,
|
||||
class: sub.cls,
|
||||
nation: sub.nation,
|
||||
pts: sub.pts,
|
||||
k: sub.k,
|
||||
d: sub.d,
|
||||
atk: sub.atk,
|
||||
def: sub.def,
|
||||
heal: sub.heal,
|
||||
|
|
@ -86,5 +92,5 @@ export function submitScore(sub: ScoreSubmission): void {
|
|||
};
|
||||
|
||||
upsertScore(score);
|
||||
recordScore(sub.usermapKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
||||
recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
import cron from "node-cron";
|
||||
import { Client } from "discord.js";
|
||||
import { Client, TextChannel } from "discord.js";
|
||||
import { cfg } from "./config";
|
||||
import { TGSlot } from "../types";
|
||||
import { polls, updatePollMessage } from "@systems/poll";
|
||||
|
||||
type PollCallback = (slot: TGSlot) => Promise<void>;
|
||||
type PollCallback = (slot: TGSlot) => Promise<void>;
|
||||
type CloseCallback = (slot: TGSlot) => Promise<void>;
|
||||
type LockCallback = (slot: TGSlot) => Promise<void>;
|
||||
|
||||
let _scheduledTasks: cron.ScheduledTask[] = [];
|
||||
|
||||
export function scheduleSlots(
|
||||
client: Client,
|
||||
onPollOpen: PollCallback,
|
||||
onPollClose: CloseCallback
|
||||
client: Client,
|
||||
onPollOpen: PollCallback,
|
||||
onPollLock: LockCallback,
|
||||
onPollClose: CloseCallback,
|
||||
): void {
|
||||
// Clear existing schedules
|
||||
_scheduledTasks.forEach((t) => t.stop());
|
||||
_scheduledTasks = [];
|
||||
|
||||
|
|
@ -21,37 +23,46 @@ export function scheduleSlots(
|
|||
const slots = cfg("slots").filter((s) => s.active);
|
||||
|
||||
for (const slot of slots) {
|
||||
// Parse poll open time
|
||||
// Poll open
|
||||
const [openHour, openMin] = slot.pollOpens.split(":").map(Number);
|
||||
|
||||
// Schedule poll open
|
||||
const openTask = cron.schedule(
|
||||
_scheduledTasks.push(cron.schedule(
|
||||
`${openMin} ${openHour} * * *`,
|
||||
() => onPollOpen(slot),
|
||||
{ timezone: tz }
|
||||
);
|
||||
_scheduledTasks.push(openTask);
|
||||
));
|
||||
|
||||
// Schedule poll close (tgHour + closesAfter minutes)
|
||||
// Poll lock — exactly at tgHour (TG start, voting closes, lockedYesKeys snapshotted)
|
||||
_scheduledTasks.push(cron.schedule(
|
||||
`0 ${slot.tgHour} * * *`,
|
||||
() => onPollLock(slot),
|
||||
{ timezone: tz }
|
||||
));
|
||||
|
||||
// Poll close — tgHour + closesAfter minutes (TG end, Submit Score button appears)
|
||||
const closeMinTotal = slot.tgHour * 60 + slot.closesAfter;
|
||||
const closeHour = Math.floor(closeMinTotal / 60) % 24;
|
||||
const closeMin = closeMinTotal % 60;
|
||||
|
||||
const closeTask = cron.schedule(
|
||||
_scheduledTasks.push(cron.schedule(
|
||||
`${closeMin} ${closeHour} * * *`,
|
||||
() => onPollClose(slot),
|
||||
{ timezone: tz }
|
||||
);
|
||||
_scheduledTasks.push(closeTask);
|
||||
));
|
||||
|
||||
_scheduledTasks.push(cron.schedule("0 0 * * *", async () => {
|
||||
const state = polls.get(slot.tgHour);
|
||||
if (!state?.locked) return; // only if poll has been locked (TG happened)
|
||||
const channel = await (client as any).channels.fetch(cfg("pollChannelId")) as TextChannel;
|
||||
await updatePollMessage(channel, slot.tgHour, undefined, false);
|
||||
console.log(`[${new Date().toISOString()}] Submit Score button removed.`);
|
||||
}, { timezone: tz }));
|
||||
}
|
||||
|
||||
// Weekly reset — Monday 00:00
|
||||
const resetTask = cron.schedule("0 0 * * 1", () => {
|
||||
_scheduledTasks.push(cron.schedule("0 0 * * 1", () => {
|
||||
const { resetWeek } = require("./wrank");
|
||||
resetWeek();
|
||||
console.log("W.Rank weekly reset complete.");
|
||||
}, { timezone: tz });
|
||||
_scheduledTasks.push(resetTask);
|
||||
}, { timezone: tz }));
|
||||
|
||||
console.log(`Scheduled ${slots.length} slot(s).`);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { GuildMember } from "discord.js";
|
||||
import { getImpersonation } from "./impersonate";
|
||||
import { ResolvedUser } from "../types";
|
||||
import { getUsermapEntry } from "./messages";
|
||||
import { getActiveCharacter } from "./characters";
|
||||
|
|
@ -10,15 +11,21 @@ export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
|
|||
const globalNickname = member.user.globalName ?? null;
|
||||
const displayName = serverNickname ?? globalNickname ?? discordUsername;
|
||||
|
||||
const entry = getUsermapEntry(discordUsername);
|
||||
const usermapKey = entry?.file ?? null;
|
||||
const aliases = entry?.aliases ?? [];
|
||||
const activeChar = usermapKey ? getActiveCharacter(usermapKey) : null;
|
||||
// Check for active impersonation
|
||||
const impersonatedKey = getImpersonation(member.user.id);
|
||||
|
||||
const entry = impersonatedKey ? { file: impersonatedKey, aliases: [] } : getUsermapEntry(discordUsername);
|
||||
const userKey = entry?.file ?? null;
|
||||
const aliases = entry?.aliases ?? [];
|
||||
const activeChar = userKey ? getActiveCharacter(userKey) : null;
|
||||
// lookupUsername is used for message system lookups — use impersonated key if impersonating
|
||||
const lookupUsername = impersonatedKey ?? discordUsername;
|
||||
|
||||
return {
|
||||
userId: member.user.id,
|
||||
discordUsername,
|
||||
usermapKey,
|
||||
lookupUsername,
|
||||
userKey,
|
||||
displayName,
|
||||
serverNickname,
|
||||
globalNickname,
|
||||
|
|
@ -28,9 +35,9 @@ export async function resolveUser(member: GuildMember): Promise<ResolvedUser> {
|
|||
}
|
||||
|
||||
// Resolve a user by their usermap key (for officer commands using <name> arg)
|
||||
export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
|
||||
export function resolveByUsermapKey(key: string): { userKey: string; activeCharacter: ReturnType<typeof getActiveCharacter> } {
|
||||
return {
|
||||
usermapKey: key,
|
||||
userKey: key,
|
||||
activeCharacter: getActiveCharacter(key),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export function getWeek(weekKey: string): WRankWeek | null {
|
|||
|
||||
// Add or update a score submission for a player
|
||||
export function recordScore(
|
||||
usermapKey: string,
|
||||
userKey: string,
|
||||
characterName: string,
|
||||
cls: ClassKey,
|
||||
nation: Nation,
|
||||
|
|
@ -56,11 +56,11 @@ export function recordScore(
|
|||
const week = ensureWeek(weekKey);
|
||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||
|
||||
const existing = list.find((e) => e.usermapKey === usermapKey);
|
||||
const existing = list.find((e) => e.characterName === characterName);
|
||||
|
||||
if (existing) {
|
||||
// Check if this slot was already counted
|
||||
const alreadyCounted = week.scoreIndex[usermapKey]?.includes(historyKey);
|
||||
const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey);
|
||||
if (!alreadyCounted) {
|
||||
existing.weeklyPoints += pts;
|
||||
existing.tgCount += 1;
|
||||
|
|
@ -75,7 +75,7 @@ export function recordScore(
|
|||
existing.nation = nation;
|
||||
} else {
|
||||
list.push({
|
||||
usermapKey,
|
||||
userKey,
|
||||
characterName,
|
||||
class: cls,
|
||||
nation,
|
||||
|
|
@ -87,9 +87,10 @@ export function recordScore(
|
|||
}
|
||||
|
||||
// Update score index
|
||||
if (!week.scoreIndex[usermapKey]) week.scoreIndex[usermapKey] = [];
|
||||
if (!week.scoreIndex[usermapKey].includes(historyKey)) {
|
||||
week.scoreIndex[usermapKey].push(historyKey);
|
||||
const indexKey = characterName;
|
||||
if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = [];
|
||||
if (!week.scoreIndex[indexKey].includes(historyKey)) {
|
||||
week.scoreIndex[indexKey].push(historyKey);
|
||||
}
|
||||
|
||||
recomputeRanks(week, nation);
|
||||
|
|
@ -101,7 +102,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void {
|
|||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||
const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints);
|
||||
sorted.forEach((entry, i) => {
|
||||
const live = list.find((e) => e.usermapKey === entry.usermapKey)!;
|
||||
const live = list.find((e) => e.characterName === entry.characterName)!;
|
||||
live.previousRank = live.currentRank || undefined;
|
||||
live.currentRank = i + 1;
|
||||
});
|
||||
|
|
@ -117,14 +118,14 @@ function updateBringer(week: WRankWeek): void {
|
|||
const qualified = week.entries[nation]
|
||||
.filter((e) => e.tgCount >= goal)
|
||||
.sort((a, b) => a.currentRank - b.currentRank);
|
||||
week.bringer[nation] = qualified[0]?.usermapKey ?? null;
|
||||
week.bringer[nation] = qualified[0]?.characterName ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setBringerOverride(nation: Nation, usermapKey: string): void {
|
||||
export function setBringerOverride(nation: Nation, charName: string): void {
|
||||
const week = ensureWeek(getWeekKey());
|
||||
if (nation === "Capella") week.bringer.capellaOverride = usermapKey;
|
||||
else week.bringer.procyonOverride = usermapKey;
|
||||
if (nation === "Capella") week.bringer.capellaOverride = charName;
|
||||
else week.bringer.procyonOverride = charName;
|
||||
saveWRank();
|
||||
}
|
||||
|
||||
|
|
@ -142,10 +143,10 @@ export function getBringer(nation: Nation): string | null {
|
|||
return week.bringer.procyonOverride ?? week.bringer.procyon;
|
||||
}
|
||||
|
||||
export function getEntry(usermapKey: string, nation: Nation): WRankEntry | null {
|
||||
export function getEntry(characterName: string, nation: Nation): WRankEntry | null {
|
||||
const week = getCurrentWeek();
|
||||
const list = week.entries[nation.toLowerCase() as "capella" | "procyon"];
|
||||
return list.find((e) => e.usermapKey === usermapKey) ?? null;
|
||||
return list.find((e) => e.characterName === characterName) ?? null;
|
||||
}
|
||||
|
||||
// Called every Monday 00:00 by cron
|
||||
|
|
|
|||
25
src/types.ts
|
|
@ -47,7 +47,7 @@ export interface Character {
|
|||
}
|
||||
|
||||
export interface CharacterMap {
|
||||
[usermapKey: string]: {
|
||||
[userKey: string]: {
|
||||
characters: Character[];
|
||||
};
|
||||
}
|
||||
|
|
@ -68,7 +68,7 @@ export interface AccountData {
|
|||
}
|
||||
|
||||
export interface AccountMap {
|
||||
[usermapKey: string]: AccountData;
|
||||
[userKey: string]: AccountData;
|
||||
}
|
||||
|
||||
// ─── Usermap ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -94,7 +94,7 @@ export interface TGSlot {
|
|||
// ─── Poll ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VoteEntry {
|
||||
usermapKey: string;
|
||||
userKey: string;
|
||||
displayName: string; // server nickname → global nickname → username
|
||||
characterName?: string; // active character name at time of vote
|
||||
characterClass?: ClassKey; // snapshotted
|
||||
|
|
@ -107,6 +107,7 @@ export interface VoteEntry {
|
|||
publicMessageOverride?: string;// set by officer via /tg poll set-message
|
||||
ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral
|
||||
borrowedFrom?: string // Borrowed character from who
|
||||
discordId?: string; // real Discord ID of the voter (for notifications)
|
||||
}
|
||||
|
||||
export interface PollState {
|
||||
|
|
@ -118,16 +119,19 @@ export interface PollState {
|
|||
confirmed: "yes" | "no" | null;
|
||||
lockMessage?: string;
|
||||
confirmMessage?: string;
|
||||
lockedYesKeys?: Set<string>; // snapshot of userKeys in yes at lock time
|
||||
}
|
||||
|
||||
// ─── Scores ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TGScore {
|
||||
usermapKey: string;
|
||||
userKey: string;
|
||||
characterName: string;
|
||||
class: ClassKey;
|
||||
nation: Nation; // snapshotted at submission time
|
||||
pts: number;
|
||||
k?: number;
|
||||
d?: number;
|
||||
atk?: number;
|
||||
def?: number;
|
||||
heal?: number;
|
||||
|
|
@ -135,6 +139,7 @@ export interface TGScore {
|
|||
slot: number; // TG hour
|
||||
date: string; // YYYY-MM-DD
|
||||
submittedByOfficer: boolean;
|
||||
playedBy?: string; // userKey of who actually played (if borrowed)
|
||||
}
|
||||
|
||||
// ─── TG Result ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -160,7 +165,7 @@ export interface TGResult {
|
|||
// ─── W.Rank ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WRankEntry {
|
||||
usermapKey: string;
|
||||
userKey: string;
|
||||
characterName: string; // snapshotted
|
||||
class: ClassKey; // snapshotted
|
||||
nation: Nation; // snapshotted
|
||||
|
|
@ -177,10 +182,10 @@ export interface WRankWeek {
|
|||
procyon: WRankEntry[];
|
||||
};
|
||||
scoreIndex: {
|
||||
[usermapKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"]
|
||||
[userKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"]
|
||||
};
|
||||
bringer: {
|
||||
capella: string | null; // usermapKey of bringer, null if none qualified
|
||||
capella: string | null; // userKey of bringer, null if none qualified
|
||||
procyon: string | null;
|
||||
capellaOverride?: string; // manually set by officer
|
||||
procyonOverride?: string;
|
||||
|
|
@ -195,7 +200,7 @@ export interface WRankData {
|
|||
|
||||
export interface BringerState {
|
||||
currentWeek: string; // "2026-W22"
|
||||
capella: string | null; // usermapKey
|
||||
capella: string | null; // userKey
|
||||
procyon: string | null;
|
||||
capellaOverride?: string;
|
||||
procyonOverride?: string;
|
||||
|
|
@ -231,6 +236,7 @@ export interface BotConfig {
|
|||
showNationTotalsInHeader?: boolean;
|
||||
showNoInNationField?: boolean;
|
||||
borrowRequestExpiryMs?: number; // 0 = never expire (default)
|
||||
conflictReclaimBehavior?: "revert" | "remove"
|
||||
}
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||
|
|
@ -266,7 +272,8 @@ export interface EmojiMap {
|
|||
export interface ResolvedUser {
|
||||
userId: string;
|
||||
discordUsername: string; // interaction.user.username
|
||||
usermapKey: string | null; // resolved from usermap
|
||||
userKey: string | null; // resolved from usermap
|
||||
lookupUsername: string | null;
|
||||
displayName: string; // server nickname → global nickname → username
|
||||
serverNickname: string | null;
|
||||
globalNickname: string | null;
|
||||
|
|
|
|||
60
src/utils.ts
|
|
@ -1,18 +1,56 @@
|
|||
import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js";
|
||||
|
||||
const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false";
|
||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||
// Poll vote confirmation messages (Yes/No button responses)
|
||||
const POLL_EPHEMERAL_ENABLED = process.env.POLL_EPHEMERAL_ENABLED !== "false";
|
||||
// Command output messages (score, rank, status etc.) — always on by default
|
||||
const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false";
|
||||
const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000");
|
||||
|
||||
export async function replyAndDelete(
|
||||
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||
// For poll button responses
|
||||
export async function pollReplyAndDelete(
|
||||
interaction: ButtonInteraction,
|
||||
content: string | null
|
||||
): Promise<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);
|
||||
if (!content || !POLL_EPHEMERAL_ENABLED) return;
|
||||
try {
|
||||
const reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true });
|
||||
if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||
} catch (err: any) {
|
||||
console.error("[pollReplyAndDelete] error:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// For command responses — always sends regardless of POLL_EPHEMERAL_ENABLED
|
||||
export async function replyAndDelete(
|
||||
interaction: ChatInputCommandInteraction | ButtonInteraction,
|
||||
content: string | null,
|
||||
forceShow = false // set true for meaningful output that should always show
|
||||
): Promise<void> {
|
||||
const enabled = forceShow || COMMAND_EPHEMERAL_ENABLED;
|
||||
|
||||
if (!content || !enabled) {
|
||||
if (interaction.isButton()) {
|
||||
if (!interaction.deferred && !interaction.replied) await interaction.deferUpdate();
|
||||
return;
|
||||
}
|
||||
if (!interaction.deferred && !interaction.replied) {
|
||||
await interaction.deferReply({ ephemeral: true });
|
||||
await interaction.deleteReply().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let reply: any;
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true });
|
||||
} else {
|
||||
reply = await interaction.reply({ content, ephemeral: true, fetchReply: true });
|
||||
}
|
||||
if (reply && EPHEMERAL_DELETE_MS > 0) {
|
||||
setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[replyAndDelete] error:", err.message);
|
||||
}
|
||||
}
|
||||