diff --git a/.env b/.env index cfaf247..8705b81 100644 --- a/.env +++ b/.env @@ -1,7 +1,13 @@ -DISCORD_TOKEN=MTUxMDc3NjgwNDE4NzU3NDMwMw.GgKLwR.eWiKvwpr03kVlUGquWxMzlOPC9h4Y_zvMh7KJM -POLL_CHANNEL_ID=1510761562997133333 -RESULTS_CHANNEL_ID=1510761595809304687 -SCORE_CHANNEL_ID=1510761785794232442 -CLIENT_ID=1510776804187574303 -GUILD_ID=402115662149058561 -EPHEMERAL_ENABLED=false \ No newline at end of file +DISCORD_TOKEN=MTUxMDk1OTgxNDYyMzEwNTA0NA.GNY7A9.Boq4MruKRqvo1UZ5JmsCkYN7q1xCTNKuqyh1oA +POLL_CHANNEL_ID=1511006387293917355 +RESULTS_CHANNEL_ID=1511006410627088544 +SCORE_CHANNEL_ID=1511006435079884991 +CLIENT_ID=1510959814623105044 +GUILD_ID=1511006171681652858 +EPHEMERAL_DELETE_MS=0 +POLL_EPHEMERAL_ENABLED=true # voting messages (disabled during testing) +COMMAND_EPHEMERAL_ENABLED=true # command outputs (always on) +AUTO_VOTE_ON_CONFLICT_SWITCH=true +IMPERSONATE_RESET_ON_POLL=false +IMPERSONATE_INDICATOR=true +RECLAIM_NOTIFY_BORROWER=true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbe35b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ + +# Environment variables — never commit these +.env + +# Runtime data — server-specific, never commit +data/config.json +data/characters.json +data/accounts.json +data/usermap.json +data/wrank.json +data/bringer.json +data/sessionPreferences.json +data/tg-history/ + +# Keep the data directory structure but not the contents +!data/.gitkeep +!data/tg-history/.gitkeep + +# Messages — user-specific files stay local +messages/users/ + +# Keep the users directory structure +!messages/users/.gitkeep + +# TypeScript build output +dist/ + +# Logs +*.log +npm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9e0ff56..1e689a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ RUN npm install COPY src/ ./src/ COPY tsconfig.json ./ -CMD ["npx", "ts-node", "src/index.ts"] +CMD ["npx", "ts-node", "-r", "tsconfig-paths/register", "src/index.ts"] \ No newline at end of file diff --git a/data/characters.json b/data/characters.json index 01cd3b0..db2f30c 100644 --- a/data/characters.json +++ b/data/characters.json @@ -6,7 +6,7 @@ "class": "FB", "level": 79, "nation": "Procyon", - "active": false, + "active": true, "sharedWith": [ "invicjusz" ] @@ -16,7 +16,10 @@ "class": "WI", "level": 79, "nation": "Procyon", - "active": true + "active": false, + "sharedWith": [ + "invicjusz" + ] } ] }, @@ -38,7 +41,10 @@ "class": "BL", "level": 79, "nation": "Capella", - "active": true + "active": true, + "sharedWith": [ + "flash" + ] } ] }, diff --git a/data/tg-history/2026-06-01-20.json b/data/tg-history/2026-06-01-20.json index 0f614da..5f31433 100644 --- a/data/tg-history/2026-06-01-20.json +++ b/data/tg-history/2026-06-01-20.json @@ -14,17 +14,6 @@ } }, "scores": [ - { - "usermapKey": "flash", - "characterName": "»Flash«", - "class": "WI", - "nation": "Procyon", - "pts": 4000, - "submittedAt": "2026-06-01T03:18:24.563Z", - "slot": 20, - "date": "2026-06-01", - "submittedByOfficer": true - }, { "usermapKey": "invicjusz", "characterName": "ElementalEnchant", @@ -35,6 +24,17 @@ "slot": 20, "date": "2026-06-01", "submittedByOfficer": true + }, + { + "usermapKey": "flash", + "characterName": "«Flash»", + "class": "FB", + "nation": "Procyon", + "pts": 2000, + "submittedAt": "2026-06-01T22:07:39.907Z", + "slot": 20, + "date": "2026-06-01", + "submittedByOfficer": true } ] } \ No newline at end of file diff --git a/data/tg-history/2026-06-01-22.json b/data/tg-history/2026-06-01-22.json index d37c85a..746b79f 100644 --- a/data/tg-history/2026-06-01-22.json +++ b/data/tg-history/2026-06-01-22.json @@ -19,8 +19,8 @@ "characterName": "»Flash«", "class": "WI", "nation": "Procyon", - "pts": 2000, - "submittedAt": "2026-06-01T03:22:14.287Z", + "pts": 1000, + "submittedAt": "2026-06-01T22:05:28.186Z", "slot": 22, "date": "2026-06-01", "submittedByOfficer": false diff --git a/data/wrank.json b/data/wrank.json index 0c2404c..e03ed18 100644 --- a/data/wrank.json +++ b/data/wrank.json @@ -3,14 +3,31 @@ "weekKey": "2026-W23", "entries": { "capella": [], - "procyon": [] + "procyon": [ + { + "usermapKey": "flash", + "characterName": "»Flash«", + "class": "WI", + "nation": "Procyon", + "weeklyPoints": 1861.1111111111113, + "tgCount": 3, + "currentRank": 1, + "previousRank": 1 + } + ] + }, + "scoreIndex": { + "flash": [ + "2026-06-01-20", + "2026-06-01-22", + "2026-06-02-20" + ] }, - "scoreIndex": {}, "bringer": { "capella": null, "procyon": null, - "procyonOverride": "flash", - "capellaOverride": "zephyr" + "procyonOverride": "»Flash«", + "capellaOverride": "XefronYokuda" } } } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1d8e859..4417708 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,17 @@ services: - tg-bot: + tg-bot-dev: build: - context: /opt/docker/tg-bot-ts - image: tg-bot-ts:latest - container_name: tg-bot-ts + context: /opt/docker/tg-bot-ts-dev + image: tg-bot-ts-dev:latest + container_name: tg-bot-ts-dev restart: unless-stopped env_file: - - /opt/docker/tg-bot-ts/.env + - /opt/docker/tg-bot-ts-dev/.env volumes: - - /opt/docker/tg-bot-ts/src:/app/src - - /opt/docker/tg-bot-ts/data:/app/data - - /opt/docker/tg-bot-ts/messages:/app/messages - - /opt/docker/tg-bot-ts/tsconfig.json:/app/tsconfig.json + - /opt/docker/tg-bot-ts-dev/src:/app/src + - /opt/docker/tg-bot-ts-dev/data:/app/data + - /opt/docker/tg-bot-ts-dev/scripts:/app/scripts + - /opt/docker/tg-bot-ts-dev/messages:/app/messages + - /opt/docker/tg-bot-ts-dev/emoji-uploads:/app/emoji-uploads + - /opt/docker/tg-bot-ts-dev/tsconfig.json:/app/tsconfig.json + - /opt/docker/tg-bot-ts-dev/data/sessionPreferences.json:/app/data/sessionPreferences.json diff --git a/emoji-uploads/bl.png b/emoji-uploads/bl.png new file mode 100644 index 0000000..ec5bff1 Binary files /dev/null and b/emoji-uploads/bl.png differ diff --git a/emoji-uploads/borrowed.png b/emoji-uploads/borrowed.png new file mode 100644 index 0000000..6139bcd Binary files /dev/null and b/emoji-uploads/borrowed.png differ diff --git a/emoji-uploads/capella.png b/emoji-uploads/capella.png new file mode 100644 index 0000000..dc4e72c Binary files /dev/null and b/emoji-uploads/capella.png differ diff --git a/emoji-uploads/dm.png b/emoji-uploads/dm.png new file mode 100644 index 0000000..c916547 Binary files /dev/null and b/emoji-uploads/dm.png differ diff --git a/emoji-uploads/fa.png b/emoji-uploads/fa.png new file mode 100644 index 0000000..3c710a0 Binary files /dev/null and b/emoji-uploads/fa.png differ diff --git a/emoji-uploads/fb.png b/emoji-uploads/fb.png new file mode 100644 index 0000000..eb5f913 Binary files /dev/null and b/emoji-uploads/fb.png differ diff --git a/emoji-uploads/fg.png b/emoji-uploads/fg.png new file mode 100644 index 0000000..2f5bd0c Binary files /dev/null and b/emoji-uploads/fg.png differ diff --git a/emoji-uploads/fs.png b/emoji-uploads/fs.png new file mode 100644 index 0000000..e6ef96f Binary files /dev/null and b/emoji-uploads/fs.png differ diff --git a/emoji-uploads/gl.png b/emoji-uploads/gl.png new file mode 100644 index 0000000..2c64ca6 Binary files /dev/null and b/emoji-uploads/gl.png differ diff --git a/emoji-uploads/kd.png b/emoji-uploads/kd.png new file mode 100644 index 0000000..c3e3a27 Binary files /dev/null and b/emoji-uploads/kd.png differ diff --git a/emoji-uploads/procyon.png b/emoji-uploads/procyon.png new file mode 100644 index 0000000..6352d87 Binary files /dev/null and b/emoji-uploads/procyon.png differ diff --git a/emoji-uploads/rank.png b/emoji-uploads/rank.png new file mode 100644 index 0000000..cf2a530 Binary files /dev/null and b/emoji-uploads/rank.png differ diff --git a/emoji-uploads/score.png b/emoji-uploads/score.png new file mode 100644 index 0000000..58731b2 Binary files /dev/null and b/emoji-uploads/score.png differ diff --git a/emoji-uploads/wa.png b/emoji-uploads/wa.png new file mode 100644 index 0000000..f74e45e Binary files /dev/null and b/emoji-uploads/wa.png differ diff --git a/emoji-uploads/wi.png b/emoji-uploads/wi.png new file mode 100644 index 0000000..6b424ac Binary files /dev/null and b/emoji-uploads/wi.png differ diff --git a/messages/emojis.json b/messages/emojis.json index cf6404d..dc97d6a 100644 --- a/messages/emojis.json +++ b/messages/emojis.json @@ -1,15 +1,15 @@ { - "capella": "<:Capella:1477082112560726238>", - "procyon": "<:Procyon:1477082175181426738>", - "bl": "<:bl:1510827912767475742>", - "fb": "<:fb:1510825907374395452>", - "fs": "<:fs:1510828058112954501>", - "fa": "<:fa:1510823955034935326>", - "fg": "<:fg:1510822372373037207>", - "gl": "<:gl:1510826513484873909>", - "dm": "<:dm:1510820686971670538>", - "wi": "<:wi:1510805237047230464>", - "wa": "<:wa:1510827932376109218>", + "capella": "<:capella:1511020911027814453>", + "procyon": "<:procyon:1511020943323955301>", + "bl": "<:bl:1511014332685881364>", + "fb": "<:fb:1511020923510194428>", + "fs": "<:fs:1511020931542417459>", + "fa": "<:fa:1511020918929887434>", + "fg": "<:fg:1511020927461097482>", + "gl": "<:gl:1511020935463833711>", + "dm": "<:dm:1511020914974658612>", + "wi": "<:wi:1511020959706910913>", + "wa": "<:wa:1511020955231715448>", "wrank_up": "", "wrank_down": "", "atk": "", @@ -36,8 +36,8 @@ "wrank_9_gold": "", "wrank_10": "", "wrank_10_gold": "", - "kd": "", - "score": "", - "rank": "", - "borrowed": "🔗" -} + "kd": "<:kd:1511020939226124339>", + "score": "<:score:1511020950718513172>", + "rank": "<:rank:1511020947019137187>", + "borrowed": "<:borrowed:1511020906754085057>" +} \ No newline at end of file diff --git a/nodemon.json b/nodemon.json index 0b84199..c0af92e 100644 --- a/nodemon.json +++ b/nodemon.json @@ -2,5 +2,5 @@ "watch": ["src"], "ext": "ts", "ignore": ["src/**/*.spec.ts"], - "exec": "ts-node src/index.ts" -} + "exec": "ts-node -r tsconfig-paths/register src/index.ts" +} \ No newline at end of file diff --git a/package.json b/package.json index 9898fcd..e489686 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "description": "Cabal Online TG planning and tracking bot", "main": "src/index.ts", "scripts": { - "start": "ts-node src/index.ts", + "start": "ts-node -r tsconfig-paths/register src/index.ts", "dev": "nodemon", - "register": "ts-node src/index.ts --register" + "register": "ts-node -r tsconfig-paths/register src/index.ts --register", + "aliases": "ts-node scripts/generate-aliases.ts" }, "dependencies": { "discord.js": "^14.15.3", @@ -17,6 +18,7 @@ "@types/node-cron": "^3.0.0", "nodemon": "^3.1.0", "ts-node": "^10.9.2", - "typescript": "^5.4.0" + "typescript": "^5.4.0", + "tsconfig-paths": "^4.2.0" } -} +} \ No newline at end of file diff --git a/scripts/generate-aliases.ts b/scripts/generate-aliases.ts new file mode 100644 index 0000000..4b6620e --- /dev/null +++ b/scripts/generate-aliases.ts @@ -0,0 +1,76 @@ +/** + * Generates path aliases in tsconfig.json for every directory under src/ + * and optionally data/ and messages/. + * + * Usage: npx ts-node scripts/generate-aliases.ts + */ + + import fs from "fs"; + import path from "path"; + + const TSCONFIG_PATH = path.join(__dirname, "../tsconfig.json"); + const SRC_DIR = path.join(__dirname, "../src"); + + function getDirs(dir: string, prefix: string): Record { + const result: Record = {}; + if (!fs.existsSync(dir)) return result; + + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const relPath = path.join(prefix, entry.name); + const aliasKey = `@${entry.name}/*`; + const aliasVal = [`src/${relPath}/*`]; + result[aliasKey] = aliasVal; + + // Also add a direct alias for files in this dir (e.g. @utils → src/utils) + const directKey = `@${entry.name}`; + result[directKey] = [`src/${relPath}`]; + } + return result; + } + + // Also alias every .ts file directly under src/ (e.g. @utils → src/utils) + function getFiles(dir: string): Record { + const result: Record = {}; + if (!fs.existsSync(dir)) return result; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; + const name = path.basename(entry.name, ".ts"); + result[`@${name}`] = [`src/${name}`]; + } + return result; + } + + const tsconfig = JSON.parse(fs.readFileSync(TSCONFIG_PATH, "utf8")); + + // Generate aliases for all dirs under src/ + const newPaths: Record = { + "@src/*": ["src/*"], + ...getDirs(SRC_DIR, ""), + ...getFiles(SRC_DIR), + }; + + // Also scan subdirs (systems, subcommands, handlers, commands) + for (const subdir of ["systems", "subcommands", "handlers", "commands"]) { + const full = path.join(SRC_DIR, subdir); + if (!fs.existsSync(full)) continue; + for (const entry of fs.readdirSync(full, { withFileTypes: true })) { + const name = entry.isDirectory() ? entry.name : path.basename(entry.name, ".ts"); + if (!entry.isDirectory() && !entry.name.endsWith(".ts")) continue; + // Don't override parent alias + if (!newPaths[`@${name}`]) { + newPaths[`@${name}`] = entry.isDirectory() + ? [`src/${subdir}/${name}/*`] + : [`src/${subdir}/${name}`]; + } + if (entry.isDirectory() && !newPaths[`@${name}/*`]) { + newPaths[`@${name}/*`] = [`src/${subdir}/${name}/*`]; + } + } + } + + tsconfig.compilerOptions.paths = newPaths; + fs.writeFileSync(TSCONFIG_PATH, JSON.stringify(tsconfig, null, 2)); + + console.log(`✅ Generated ${Object.keys(newPaths).length} path aliases in tsconfig.json`); + console.log(Object.keys(newPaths).sort().join("\n")); \ No newline at end of file diff --git a/scripts/upload-emojis.ts b/scripts/upload-emojis.ts new file mode 100644 index 0000000..b658158 --- /dev/null +++ b/scripts/upload-emojis.ts @@ -0,0 +1,110 @@ +/** + * Bulk emoji upload script + * Usage: npx ts-node scripts/upload-emojis.ts [emoji_dir] + * + * Place emoji PNG files in a directory named after the emoji key. + * Example: fb.png, wi.png, capella.png, wrank_1.png, wrank_1_gold.png + * + * Automatically updates messages/emojis.json with the uploaded emoji IDs. + */ + + import { REST, Routes } from "discord.js"; + import fs from "fs"; + import path from "path"; + + // Load .env manually since we're outside the bot + const envPath = path.join(__dirname, "../.env"); + if (fs.existsSync(envPath)) { + for (const line of fs.readFileSync(envPath, "utf8").split("\n")) { + const [key, ...rest] = line.split("="); + if (key && rest.length) process.env[key.trim()] = rest.join("=").trim(); + } + } + + const TOKEN = process.env.DISCORD_TOKEN!; + const GUILD_ID = process.env.GUILD_ID!; + + if (!TOKEN || !GUILD_ID) { + console.error("❌ DISCORD_TOKEN and GUILD_ID must be set in .env"); + process.exit(1); + } + + const emojiDir = process.argv[2] ?? path.join(__dirname, "../emoji-uploads"); + const emojisPath = path.join(__dirname, "../messages/emojis.json"); + + if (!fs.existsSync(emojiDir)) { + console.error(`❌ Emoji directory not found: ${emojiDir}`); + console.error(` Create it and place your emoji PNG files inside.`); + process.exit(1); + } + + const rest = new REST({ version: "10" }).setToken(TOKEN); + + async function uploadEmojis(): Promise { + const files = fs.readdirSync(emojiDir).filter((f) => + [".png", ".jpg", ".gif", ".webp"].includes(path.extname(f).toLowerCase()) + ); + + if (files.length === 0) { + console.error("❌ No image files found in the emoji directory."); + process.exit(1); + } + + // Load existing emojis.json + let emojiMap: Record = {}; + try { + emojiMap = JSON.parse(fs.readFileSync(emojisPath, "utf8")); + } catch { + console.warn("⚠️ Could not load emojis.json — will create fresh mapping."); + } + + console.log(`📁 Found ${files.length} file(s) in ${emojiDir}\n`); + + // Fetch existing guild emojis to skip duplicates + const existing = await rest.get(Routes.guildEmojis(GUILD_ID)) as any[]; + const existingMap = new Map(existing.map((e: any) => [e.name, e.id])); + + let uploaded = 0; + let skipped = 0; + let failed = 0; + + for (const file of files) { + const emojiName = path.basename(file, path.extname(file)); + const filePath = path.join(emojiDir, file); + const ext = path.extname(file).toLowerCase(); + const mimeType = ext === ".gif" ? "image/gif" : ext === ".webp" ? "image/webp" : "image/png"; + + if (existingMap.has(emojiName)) { + const formatted = `<:${emojiName}:${existingMap.get(emojiName)}>`; + emojiMap[emojiName] = formatted; + console.log(`⏭️ Already exists: ${emojiName} → ${formatted}`); + skipped++; + continue; + } + + try { + const base64 = `data:${mimeType};base64,${fs.readFileSync(filePath).toString("base64")}`; + const result = await rest.post(Routes.guildEmojis(GUILD_ID), { + body: { name: emojiName, image: base64 }, + }) as any; + + const formatted = `<:${emojiName}:${result.id}>`; + emojiMap[emojiName] = formatted; + console.log(`✅ Uploaded: ${emojiName} → ${formatted}`); + uploaded++; + + // Avoid rate limiting + await new Promise((r) => setTimeout(r, 600)); + } catch (err: any) { + console.error(`❌ Failed: ${emojiName} — ${err.message}`); + failed++; + } + } + + fs.writeFileSync(emojisPath, JSON.stringify(emojiMap, null, 2)); + + console.log(`\n📊 ${uploaded} uploaded · ${skipped} skipped · ${failed} failed`); + console.log(`💾 messages/emojis.json updated`); + } + + uploadEmojis().catch(console.error); \ No newline at end of file diff --git a/src/commands/tg.ts b/src/commands/tg.ts index a1efe26..67e60ad 100644 --- a/src/commands/tg.ts +++ b/src/commands/tg.ts @@ -18,6 +18,7 @@ import { handleSetMessage, handleClearMessage, handleSetEphemeral, handleClearEp import { handleInject, handleRemoveVote } from "../subcommands/poll/inject"; import { handleSeed } from "../subcommands/poll/seed"; import { handlePurge } from "../subcommands/poll/purge"; +import { handleImpersonate } from "../subcommands/impersonate"; // Char subcommands (borrow / sharing system) import { handleCharBorrow } from "../subcommands/char/borrow"; @@ -46,6 +47,14 @@ import { handleBringerClear } from "../subcommands/bringer/clear"; import { handleSwitch } from "../subcommands/switch"; import { handleHistory } from "../subcommands/history"; +// Import char handlers here to keep tg.ts clean +import { handleCharAdd } from "../subcommands/char/add"; +import { handleCharRemove } from "../subcommands/char/remove"; +import { handleCharSetActive } from "../subcommands/char/setActive"; +import { handleCharSetNation } from "../subcommands/char/setNation"; +import { handleCharSetStats } from "../subcommands/char/setStats"; +import { handleCharActive } from "../subcommands/char/active"; + export function buildTgCommand(): SlashCommandBuilder { const cmd = new SlashCommandBuilder() .setName("tg") @@ -56,41 +65,49 @@ export function buildTgCommand(): SlashCommandBuilder { .setName("poll") .setDescription("Manage the TG poll") .addSubcommand((s) => s.setName("start").setDescription("Post a fresh TG poll") - .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false))) + .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 22)").setRequired(false)) + ) .addSubcommand((s) => s.setName("lock").setDescription("Lock the active poll") - .addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false))) + .addStringOption((o) => o.setName("message").setDescription("One-time lock message").setRequired(false)) + ) .addSubcommand((s) => s.setName("unlock").setDescription("Unlock the active poll")) .addSubcommand((s) => s.setName("confirm").setDescription("Confirm whether TG is happening") .addStringOption((o) => o.setName("decision").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addStringOption((o) => o.setName("message").setDescription("One-time confirm message").setRequired(false)) - .addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").setRequired(false))) + .addBooleanOption((o) => o.setName("tag").setDescription("Tag configured roles?").setRequired(false)) + ) .addSubcommand((s) => s.setName("reload").setDescription("Reload messages and emojis from disk")) .addSubcommand((s) => s.setName("status").setDescription("Show current poll and config status")) .addSubcommand((s) => s.setName("set-message").setDescription("Set public message override for a user") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("clear-message").setDescription("Clear public message override") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-ephemeral").setDescription("Set ephemeral message override for a user") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) .addStringOption((o) => o.setName("message").setDescription("Message to show").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("clear-ephemeral").setDescription("Clear ephemeral message override") .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(false) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("inject").setDescription("Inject a vote for a registered user") - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) .addStringOption((o) => o.setName("vote_type").setDescription("yes or no").setRequired(true) .addChoices({ name: "Yes", value: "yes" }, { name: "No", value: "no" }))) .addSubcommand((s) => s.setName("remove-vote").setDescription("Remove a vote for a registered user") - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("purge").setDescription("Delete all bot messages from the poll channel")) .addSubcommand((s) => s.setName("seed").setDescription("Inject all registered players as Yes votes for layout testing")) ); @@ -102,10 +119,17 @@ export function buildTgCommand(): SlashCommandBuilder { .addSubcommand((s) => s.setName("set").setDescription("Submit a score") .addIntegerOption((o) => o.setName("pts").setDescription("Points").setRequired(true)) .addStringOption((o) => o.setName("slot").setDescription("TG hour (e.g. 20, 8pm, midnight)").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addIntegerOption((o) => o.setName("k").setDescription("Kills").setRequired(false)) + .addIntegerOption((o) => o.setName("d").setDescription("Deaths").setRequired(false)) + .addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false)) + .addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false)) + .addIntegerOption((o) => o.setName("heal").setDescription("Healing score (FA only)").setRequired(false)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("get").setDescription("View a score") .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) ); // ── rank group ───────────────────────────────────────────────────────────── @@ -113,7 +137,8 @@ export function buildTgCommand(): SlashCommandBuilder { .setName("rank") .setDescription("W.Rank management") .addSubcommand((s) => s.setName("get").setDescription("View W.Rank") - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("post").setDescription("Post leaderboard publicly (officer only)")) ); @@ -140,7 +165,8 @@ export function buildTgCommand(): SlashCommandBuilder { .addSubcommand((s) => s.setName("set").setDescription("Manually set Bringer") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("clear").setDescription("Clear Bringer override") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" }))) @@ -148,8 +174,8 @@ export function buildTgCommand(): SlashCommandBuilder { // ── switch ───────────────────────────────────────────────────────────────── cmd.addSubcommand((s) => s.setName("switch").setDescription("Switch active character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false)) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) ); // ── char group ───────────────────────────────────────────────────────────── @@ -173,40 +199,53 @@ export function buildTgCommand(): SlashCommandBuilder { .addIntegerOption((o) => o.setName("level").setDescription("Level").setRequired(true)) .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("remove").setDescription("Remove a character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-active").setDescription("Set active character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-nation").setDescription("Change a character's nation") .addStringOption((o) => o.setName("nation").setDescription("Nation").setRequired(true) .addChoices({ name: "Capella", value: "Capella" }, { name: "Procyon", value: "Procyon" })) - .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("set-stats").setDescription("Set character combat stats") .addStringOption((o) => o.setName("char_name").setDescription("Character name (defaults to active)").setRequired(false)) .addIntegerOption((o) => o.setName("atk").setDescription("Attack score").setRequired(false)) .addIntegerOption((o) => o.setName("def").setDescription("Defense score").setRequired(false)) .addIntegerOption((o) => o.setName("heal").setDescription("Healing score").setRequired(false)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("borrow").setDescription("Request to borrow a character for this session") - .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true)) - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Grant to this user (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("accept").setDescription("Accept a borrow request") - .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("decline").setDescription("Decline a borrow request") - .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true))) + .addStringOption((o) => o.setName("name").setDescription("Requester's usermap key").setRequired(true).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("share").setDescription("Permanently share a character") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true)) - .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key to share with").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) .addSubcommand((s) => s.setName("unshare").setDescription("Revoke permanent character share") - .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true)) - .addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true)) - .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false))) + .addStringOption((o) => o.setName("char_name").setDescription("Character name").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("name").setDescription("Usermap key to revoke").setRequired(true).setAutocomplete(true)) + .addStringOption((o) => o.setName("owner").setDescription("Owner's usermap key (officer only)").setRequired(false).setAutocomplete(true)) + ) + .addSubcommand((s) => s.setName("active").setDescription("Check active character for a user") + .addStringOption((o) => o.setName("name").setDescription("Usermap key (officer: check others)").setRequired(false).setAutocomplete(true)) + ) ); // ── history ──────────────────────────────────────────────────────────────── @@ -215,6 +254,9 @@ export function buildTgCommand(): SlashCommandBuilder { .addStringOption((o) => o.setName("slot").setDescription("TG hour").setRequired(false)) ); + // ── impersonate ──────────────────────────────────────────────────────────────── + cmd.addSubcommand((s) => s.setName("impersonate").setDescription("Impersonate a registered user for testing (officer only)")); + return cmd; } @@ -284,14 +326,9 @@ export async function handleTgCommand(interaction: ChatInputCommandInteraction): if (sub === "decline") return handleCharDecline(interaction); if (sub === "share") return handleCharShare(interaction); if (sub === "unshare") return handleCharUnshare(interaction); + if (sub === "active") return handleCharActive(interaction); } - if (!group && sub === "switch") return handleSwitch(interaction); - if (!group && sub === "history") return handleHistory(interaction); + if (!group && sub === "switch") return handleSwitch(interaction); + if (!group && sub === "history") return handleHistory(interaction); + if (!group && sub === "impersonate") return handleImpersonate(interaction); } - -// Import char handlers here to keep tg.ts clean -import { handleCharAdd } from "../subcommands/char/add"; -import { handleCharRemove } from "../subcommands/char/remove"; -import { handleCharSetActive } from "../subcommands/char/setActive"; -import { handleCharSetNation } from "../subcommands/char/setNation"; -import { handleCharSetStats } from "../subcommands/char/setStats"; \ No newline at end of file diff --git a/src/handlers/autocomplete.ts b/src/handlers/autocomplete.ts new file mode 100644 index 0000000..7e93bbf --- /dev/null +++ b/src/handlers/autocomplete.ts @@ -0,0 +1,104 @@ +import { AutocompleteInteraction } from "discord.js"; +import { resolveUser } from "@systems/users"; +import { getCharacters } from "@systems/characters"; +import { cfg } from "@systems/config"; +import { getNationEmoji } from "@systems/emojis"; +import fs from "fs"; +import path from "path"; + +// ─── Autocomplete subsets ───────────────────────────────────────────────────── + +async function autocompleteCharNames( + interaction: AutocompleteInteraction, + focused: string +): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + const user = await resolveUser(member); + if (!user.userKey) return interaction.respond([]); + + const ownChars = getCharacters(user.userKey).map((c) => { + const nationEmoji = c.nation ? (getNationEmoji(c.nation) || c.nation) : ""; + return { + name: `${c.class} ${c.level} ${c.name} ${nationEmoji}`.trim(), + value: c.name, + }; + }); + + const sharedChars: { name: string; value: string }[] = []; + try { + const chars = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") + ); + for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { + if (ownerKey === user.userKey) continue; + for (const char of data.characters ?? []) { + if (char.sharedWith?.includes(user.userKey)) { + const nationEmoji = char.nation ? (getNationEmoji(char.nation) || char.nation) : ""; + sharedChars.push({ + name: `${char.class} ${char.level} ${char.name} 🔗 ${nationEmoji}`.trim(), + value: char.name, + }); + } + } + } + } catch {} + + const all = [...ownChars, ...sharedChars] + .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) + .slice(0, 25); + + await interaction.respond(all); +} + +async function autocompleteUserKeys( + interaction: AutocompleteInteraction, + focused: string +): Promise { + try { + const usermap = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") + ); + const choices = Object.entries(usermap) + .map(([, entry]: [string, any]) => { + const fileKey = typeof entry === "string" ? entry : entry.file; + const alias = typeof entry === "object" ? (entry.aliases?.[0] ?? fileKey) : fileKey; + return { name: `${alias} (${fileKey})`, value: fileKey }; + }) + .filter((c) => c.name.toLowerCase().includes(focused.toLowerCase())) + .slice(0, 25); + await interaction.respond(choices); + } catch { + await interaction.respond([]); + } +} + +async function autocompleteSlots( + interaction: AutocompleteInteraction, + focused: string +): Promise { + const slots = cfg("slots") + .filter((s) => s.active) + .map((s) => ({ name: `${s.tgHour}:00`, value: String(s.tgHour) })) + .filter((s) => s.name.includes(focused)); + await interaction.respond(slots); +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +export async function handleAutocomplete(interaction: AutocompleteInteraction): Promise { + try { + const focused = interaction.options.getFocused(true); + const optionName = focused.name; + const focusedValue = focused.value as string; + + if (optionName === "char_name") return await autocompleteCharNames(interaction, focusedValue); + if (optionName === "name") return await autocompleteUserKeys(interaction, focusedValue); + if (optionName === "slot") return await autocompleteSlots(interaction, focusedValue); + if (optionName === "owner") return await autocompleteUserKeys(interaction, focusedValue); + + await interaction.respond([]); + } catch (err) { + console.error("[autocomplete] error:", err); + try { await interaction.respond([]); } catch {} + } +} \ No newline at end of file diff --git a/src/handlers/buttons.ts b/src/handlers/buttons.ts index af44df7..9d607c9 100644 --- a/src/handlers/buttons.ts +++ b/src/handlers/buttons.ts @@ -1,21 +1,86 @@ import { ButtonInteraction, TextChannel } from "discord.js"; -import { cfg } from "../systems/config"; -import { resolveUser } from "../systems/users"; -import { resolveMessage, nowFormatted } from "../systems/messages"; -import { resolveNation } from "../systems/nations"; -import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "../systems/poll"; +import { cfg } from "@systems/config"; +import { pollReplyAndDelete } from "../utils"; +import { resolveUser } from "@systems/users"; +import { resolveMessage, nowFormatted } from "@systems/messages"; +import { resolveNation } from "@systems/nations"; +import { polls, updatePollMessage, createVoteEntry, getPublicOverride, getEphemeralOverride } from "@systems/poll"; +import { showConflictEmbed } from "@systems/conflict"; +import { getCharacters } from "@systems/characters"; +import { getImpersonation } from "@systems/impersonate"; +import { format } from "@format"; +import { Character } from "@src/types"; -const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false"; -const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); -const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); +const LOCK_AT = parseInt(process.env.LOCK_AT ?? "10"); const clickCounts = new Map(); +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function isCharacterInPoll( + state: ReturnType, + charName: string, + excludeVoteId: string, + excludeUserKey: string = "" +): { found: boolean; entryUserKey: string | null; borrowedFrom: string | undefined } { + if (!state) return { found: false, entryUserKey: null, borrowedFrom: undefined }; + for (const [otherId, entry] of state.yes.entries()) { + if (otherId !== excludeVoteId && entry.userKey !== excludeUserKey && entry.characterName === charName) { + return { found: true, entryUserKey: entry.userKey ?? null, borrowedFrom: entry.borrowedFrom }; + } + } + return { found: false, entryUserKey: null, borrowedFrom: undefined }; +} + +function isCharacterOwner(userKey: string | null, charName: string): boolean { + if (!userKey) return false; + return getCharacters(userKey).some((c) => c.name === charName); +} + +async function handleCharacterConflict( + interaction: ButtonInteraction, + userKey: string | null, + char: Character, + entryUserKey: string | null, + clicks: { yes: number; no: number }, + votedYes: boolean +): Promise { + // Decrement click since we're blocking this vote + if (votedYes) clicks.yes -= 1; + else clicks.no -= 1; + + const isOwner = isCharacterOwner(userKey, char.name); + + if (isOwner && userKey) { + const allChars = getCharacters(userKey); + const borrowedChar = allChars.find((c) => c.name === char.name); + if (borrowedChar && entryUserKey) { + await showConflictEmbed(interaction, userKey, entryUserKey, borrowedChar, allChars); + return true; + } + } + + const slot = [...polls.keys()][0]; + const slotHour = slot !== undefined ? polls.get(slot)?.slot : cfg("slots")[0]?.tgHour ?? 20; + + await interaction.followUp({ + content: `❌ ${format.char(char)} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + // content: `❌ **${char.name}** is already in the poll by another player. Switch to a different character first.`, + ephemeral: true + }); + return true; +} + +// ─── Main button handler ────────────────────────────────────────────────────── + export async function handleButton(interaction: ButtonInteraction): Promise { if (!["tg_yes", "tg_no"].includes(interaction.customId)) return; - // Defer immediately to avoid 3s timeout - await interaction.deferUpdate(); + try { + await interaction.deferUpdate(); + } catch { + return; + } const slot = [...polls.entries()].find(([, s]) => s.messageId === interaction.message.id)?.[0]; if (slot === undefined) return; @@ -24,61 +89,91 @@ export async function handleButton(interaction: ButtonInteraction): Promise 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); - } + const capella = format.nation("Capella"); + const procyon = format.nation("Procyon"); + await interaction.followUp({ content: `❌ You must be in ${capella} or ${procyon} to vote.`, ephemeral: true }); return; } // Click tracking - if (!clickCounts.has(userId)) clickCounts.set(userId, { yes: 0, no: 0 }); - const clicks = clickCounts.get(userId)!; + if (!clickCounts.has(voteId)) clickCounts.set(voteId, { yes: 0, no: 0 }); + const clicks = clickCounts.get(voteId)!; if (votedYes && clicks.yes >= LOCK_AT) return; if (!votedYes && clicks.no >= LOCK_AT) return; // Ignore same vote - if (votedYes && state.yes.has(userId)) return; - if (!votedYes && state.no.has(userId)) return; + if (votedYes && state.yes.has(voteId)) return; + if (!votedYes && state.no.has(voteId)) return; + // Increment click (may be decremented in conflict handler) if (votedYes) clicks.yes += 1; else clicks.no += 1; const clickCount = votedYes ? clicks.yes : clicks.no; - // Resolve messages — officer override takes priority - const publicMsg = getPublicOverride(userId, votedYes ? "yes" : "no") - ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname); + // Resolve messages + const publicMsg = getPublicOverride(voteId, votedYes ? "yes" : "no") + ?? resolveMessage("public", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); - const ephemeralMsg = getEphemeralOverride(userId, votedYes ? "yes" : "no") - ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, user.discordUsername, user.serverNickname, user.globalNickname); + const ephemeralMsg = getEphemeralOverride(voteId, votedYes ? "yes" : "no") + ?? resolveMessage("ephemeral", votedYes ? "yes" : "no", clickCount, lookupUsername, user.serverNickname, user.globalNickname); - const baseEntry = createVoteEntry(userId, member, user.usermapKey, user.discordUsername); + const baseEntry = createVoteEntry(voteId, member, user.userKey, lookupUsername); + // Character conflict check — applies to both Yes and No + if (baseEntry.characterName) { + const conflictChar = { + name: baseEntry.characterName!, + class: baseEntry.characterClass!, + level: baseEntry.characterLevel!, + nation: baseEntry.characterNation!, + active: false, // not needed for display + }; + + const { found, entryUserKey, borrowedFrom } = isCharacterInPoll( + state, baseEntry.characterName, voteId, user.userKey ?? "" + ); + if (found) { + await handleCharacterConflict( + interaction, user.userKey, conflictChar, + entryUserKey, clicks, votedYes + ); + return; + } + } + + // Register vote if (votedYes) { - const previousNo = state.no.get(userId); - state.no.delete(userId); - state.yes.set(userId, { + const previousNo = state.no.get(voteId); + state.no.delete(voteId); + state.yes.set(voteId, { ...baseEntry, + discordId: userId, votedAt: now, previousNoAt: previousNo?.votedAt, publicMessage: publicMsg ?? undefined, }); } else { - const previousYes = state.yes.get(userId); - state.yes.delete(userId); - state.no.set(userId, { + const previousYes = state.yes.get(voteId); + state.yes.delete(voteId); + state.no.set(voteId, { ...baseEntry, votedAt: now, + discordId: userId, previousYesAt: previousYes?.votedAt, publicMessage: publicMsg ?? undefined, }); @@ -87,18 +182,11 @@ export async function handleButton(interaction: ButtonInteraction): Promise= LOCK_AT; if (locked) state.locked = true; - // Send ephemeral follow-up (since we already deferred with deferUpdate) - if (EPHEMERAL_ENABLED) { - const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; - const content = ephemeralMsg - ? `${ephemeralMsg}${lockedSuffix}` - : locked ? "🔒 You've been locked in." : null; - - if (content) { - const reply = await interaction.followUp({ content, ephemeral: true }); - if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); - } - } + const lockedSuffix = locked ? "\n🔒 *You've been locked in.*" : ""; + const msgContent = ephemeralMsg + ? `${ephemeralMsg}${lockedSuffix}` + : locked ? "🔒 You've been locked in." : null; + await pollReplyAndDelete(interaction, msgContent); const channel = interaction.channel as TextChannel; await updatePollMessage(channel, slot); diff --git a/src/handlers/interactions.ts b/src/handlers/interactions.ts index 5a2179d..ee01719 100644 --- a/src/handlers/interactions.ts +++ b/src/handlers/interactions.ts @@ -1,20 +1,134 @@ -import { Interaction, ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; -import { handleButton } from "./buttons"; -import { handleTgCommand } from "../commands/tg"; -import { handleTgConfigCommand } from "../commands/tgConfig"; -import { handleBorrowAcceptButton } from "../subcommands/char/accept"; -import { handleBorrowDeclineButton } from "../subcommands/char/decline"; +import { Interaction, ChatInputCommandInteraction, ButtonInteraction, TextChannel } from "discord.js"; +import { handleButton } from "@handlers/buttons"; +import { handleTgCommand } from "@commands/tg"; +import { handleTgConfigCommand } from "@commands/tgConfig"; +import { handleBorrowAcceptButton } from "@subcommands/char/accept"; +import { handleBorrowDeclineButton } from "@subcommands/char/decline"; +import { handleConflictButton } from "@systems/conflict"; +import { handleImpersonateButton } from "@subcommands/impersonate"; +import { handleAutocomplete } from "@handlers/autocomplete"; +import { setActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; +import { setPersistentPreference, clearSessionBorrowForUser, getEffectiveCharacter } from "@systems/borrow"; +import { polls, updatePollMessage } from "@systems/poll"; +import { cfg } from "@systems/config"; +import { resolveMessage, nowFormatted } from "@systems/messages"; +import { format } from "@format"; +import fs from "fs"; +import path from "path"; + +async function handleSwitchAfterReclaim(btn: ButtonInteraction): Promise { + const parts = btn.customId.split(":"); + const userKey = parts[1]; + const charName = parts[2]; + const prevVoteType = (parts[3] ?? "yes") as "yes" | "no"; + + const chars = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/characters.json"), "utf8") + ); + + let resolvedChar: any = null; + let borrowedFrom: string | null = null; + + // Try own char first + const ownEntry = chars[userKey]?.characters?.find((c: any) => c.name === charName); + if (ownEntry) { + setActiveCharacter(userKey, charName); + clearSessionBorrowForUser(userKey); + resolvedChar = ownEntry; + } else { + // Try shared char + for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { + const char = data.characters?.find( + (c: any) => c.name === charName && c.sharedWith?.includes(userKey) + ); + if (char) { + setPersistentPreference(userKey, ownerKey, charName); + clearSessionBorrowForUser(userKey); + resolvedChar = char; + borrowedFrom = ownerKey; + break; + } + } + } + + if (!resolvedChar) { + await btn.reply({ content: `❌ Could not switch to **${charName}**.`, ephemeral: true }); + return; + } + + // Re-add to poll with previous vote type + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + if (state && !state.locked && state.confirmed === null) { + const { char } = getEffectiveCharacter(userKey); + const now = nowFormatted(); + const publicMsg = resolveMessage("public", prevVoteType, 1, userKey, null, null); + const voteEntry = { + userKey, + displayName: charName, + characterName: char?.name ?? charName, + characterClass: char?.class ?? resolvedChar.class, + characterLevel: char?.level ?? resolvedChar.level, + characterNation: char?.nation ?? resolvedChar.nation, + borrowedFrom: borrowedFrom ?? undefined, + discordId: btn.user.id, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }; + + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + if (entry.userKey === userKey) { + state.yes.delete(id); + state.no.delete(id); + } + } + + if (prevVoteType === "yes") { + state.yes.set(`switch_reclaim:${userKey}`, voteEntry); + } else { + state.no.set(`switch_reclaim:${userKey}`, voteEntry); + } + + const channel = await btn.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + } + + const charDisplay = resolvedChar ? format.char(resolvedChar) : charName; + const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + await btn.reply({ + content: `🔄 ${charDisplay}${borrowNote}${state ? ` — re-added to poll as **${prevVoteType}**.` : ""}`, + ephemeral: true, + }); +} export async function handleInteraction(interaction: Interaction): Promise { try { + if (interaction.isAutocomplete()) { + await handleAutocomplete(interaction); + return; + } + if (interaction.isButton()) { const btn = interaction as ButtonInteraction; - // Borrow accept/decline buttons from DM + if (btn.customId.startsWith("conflict_")) { + return await handleConflictButton(btn); + } + + if (btn.customId.startsWith("impersonate_")) { + return await handleImpersonateButton(btn); + } + + if (btn.customId.startsWith("switch_after_reclaim:")) { + return await handleSwitchAfterReclaim(btn); + } + if (btn.customId.startsWith("borrow_accept:")) { const [, ownerKey, requesterKey] = btn.customId.split(":"); return await handleBorrowAcceptButton(btn, ownerKey, requesterKey); } + if (btn.customId.startsWith("borrow_decline:")) { const [, ownerKey, requesterKey] = btn.customId.split(":"); return await handleBorrowDeclineButton(btn, ownerKey, requesterKey); diff --git a/src/index.ts b/src/index.ts index 50bb28b..fbae39c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,11 @@ client.once("clientReady", async () => { loadCharacters(); loadWRank(); + // Warm member cache + const guild = await client.guilds.fetch(GUILD_ID); + await guild.members.fetch(); + console.log(`Member cache warmed: ${guild.members.cache.size} members`); + // Register commands if --register flag passed if (process.argv.includes("--register")) { await registerCommands(); diff --git a/src/subcommands/bringer/clear.ts b/src/subcommands/bringer/clear.ts index caf04db..59f2d7a 100644 --- a/src/subcommands/bringer/clear.ts +++ b/src/subcommands/bringer/clear.ts @@ -1,10 +1,10 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { clearBringerOverride } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; -import { Nation } from "../../types"; +import { clearBringerOverride } from "@systems/wrank"; +import { replyAndDelete } from "@utils"; +import { Nation } from "@types"; export async function handleBringerClear(interaction: ChatInputCommandInteraction): Promise { const nation = interaction.options.getString("nation", true) as Nation; clearBringerOverride(nation); return void replyAndDelete(interaction, `✅ Bringer override cleared for **${nation}**.`); -} +} \ No newline at end of file diff --git a/src/subcommands/bringer/set.ts b/src/subcommands/bringer/set.ts index 5e81fd1..389aecc 100644 --- a/src/subcommands/bringer/set.ts +++ b/src/subcommands/bringer/set.ts @@ -1,12 +1,12 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { setBringerOverride } from "../../systems/wrank"; -import { replyAndDelete } from "../../utils"; -import { Nation } from "../../types"; +import { setBringerOverride } from "@systems/wrank"; +import { replyAndDelete } from "@utils"; +import { Nation } from "@types"; export async function handleBringerSet(interaction: ChatInputCommandInteraction): Promise { - const nation = interaction.options.getString("nation", true) as Nation; - const usermapKey = interaction.options.getString("name", true); + const nation = interaction.options.getString("nation", true) as Nation; + const charName = interaction.options.getString("name", true); - setBringerOverride(nation, usermapKey); - return void replyAndDelete(interaction, `✅ **${usermapKey}** set as ${nation === "Capella" ? "Luminous" : "Storm"} Bringer for this week.`); -} + setBringerOverride(nation, charName); + return void replyAndDelete(interaction, `✅ **${charName}** set as ${nation === "Capella" ? "🔆 Luminous" : "⚡ Storm"} Bringer for this week.`); +} \ No newline at end of file diff --git a/src/subcommands/char/accept.ts b/src/subcommands/char/accept.ts index 22dfb6f..57c32f0 100644 --- a/src/subcommands/char/accept.ts +++ b/src/subcommands/char/accept.ts @@ -48,7 +48,7 @@ async function acceptBorrow( const state = polls.get(slot)!; for (const map of [state.yes, state.no]) { for (const [, entry] of map) { - if (entry.usermapKey === requesterKey) { + if (entry.userKey === requesterKey) { entry.characterName = char.name; entry.characterClass = char.class; entry.characterLevel = char.level; diff --git a/src/subcommands/char/active.ts b/src/subcommands/char/active.ts new file mode 100644 index 0000000..4550470 --- /dev/null +++ b/src/subcommands/char/active.ts @@ -0,0 +1,29 @@ +import { ChatInputCommandInteraction, TextChannel } from "discord.js"; +import { cfg } from "../../systems/config"; +import { resolveUser, hasOfficerRole } from "../../systems/users"; +import { getCharacterByName, getActiveCharacter } from "../../systems/characters"; +import { addPendingRequest, setSessionBorrow, sendBorrowRequestDM, canUseCharacter } from "../../systems/borrow"; +import { polls, updatePollMessage } from "../../systems/poll"; +import { replyAndDelete } from "../../utils"; + +export async function handleCharActive(interaction: ChatInputCommandInteraction): Promise { + const nameArg = interaction.options.getString("name"); + const group = interaction.options.getSubcommandGroup(false); + const sub = interaction.options.getSubcommand(); + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + if (nameArg && !isOfficer) { + return void replyAndDelete(interaction, "❌ Only officers can check other players' active character."); + } + const targetKey = nameArg ?? (await resolveUser(member)).userKey; + if (!targetKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + + const { getEffectiveCharacter } = require("../../systems/borrow"); + const { char, borrowedFrom } = getEffectiveCharacter(targetKey); + if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${targetKey}**.`); + + const { getClassEmoji } = require("../../systems/emojis"); + const classEmoji = getClassEmoji(char.class) || char.class; + const borrowed = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + return void replyAndDelete(interaction, `${classEmoji} ${char.level} ${char.name}${borrowed}`); +} \ No newline at end of file diff --git a/src/subcommands/char/add.ts b/src/subcommands/char/add.ts index d7c71dc..d9bb741 100644 --- a/src/subcommands/char/add.ts +++ b/src/subcommands/char/add.ts @@ -14,18 +14,18 @@ export async function handleCharAdd(interaction: ChatInputCommandInteraction): P const level = interaction.options.getInteger("level", true); const nation = interaction.options.getString("nation", true) as Nation; - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const added = addCharacter(usermapKey, { name: charName, class: cls, level, nation }); + const added = addCharacter(userKey, { name: charName, class: cls, level, nation }); if (!added) return replyAndDelete(interaction, `❌ A character named **${charName}** already exists.`); return replyAndDelete(interaction, `✅ Character **«${charName}»** (${cls} · Lv${level} · ${nation}) added.`); diff --git a/src/subcommands/char/borrow.ts b/src/subcommands/char/borrow.ts index f0a00a5..2cf0f39 100644 --- a/src/subcommands/char/borrow.ts +++ b/src/subcommands/char/borrow.ts @@ -20,7 +20,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) return void replyAndDelete(interaction, "❌ Only officers can grant borrows directly."); } - const requesterKey = targetArg ?? requester.usermapKey; + const requesterKey = targetArg ?? requester.userKey; if (!requesterKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const char = getCharacterByName(ownerArg, charName); @@ -42,7 +42,7 @@ export async function handleCharBorrow(interaction: ChatInputCommandInteraction) const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; // Find the voter entry and update their character for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.usermapKey === requesterKey) { + if (entry.userKey === requesterKey) { entry.characterName = char.name; entry.characterClass = char.class; entry.characterLevel = char.level; diff --git a/src/subcommands/char/remove.ts b/src/subcommands/char/remove.ts index f15f9ba..f08d9c0 100644 --- a/src/subcommands/char/remove.ts +++ b/src/subcommands/char/remove.ts @@ -10,18 +10,18 @@ export async function handleCharRemove(interaction: ChatInputCommandInteraction) const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); - const removed = removeCharacter(usermapKey, charName); + const removed = removeCharacter(userKey, charName); if (!removed) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); return void replyAndDelete(interaction, `✅ Character **«${charName}»** removed.`); diff --git a/src/subcommands/char/setActive.ts b/src/subcommands/char/setActive.ts index 19dd25a..e22bb9c 100644 --- a/src/subcommands/char/setActive.ts +++ b/src/subcommands/char/setActive.ts @@ -9,13 +9,13 @@ import path from "path"; const CHARS_PATH = path.join(__dirname, "../../../data/characters.json"); -function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; charName: string } | null { +function findSharedChar(userKey: string, charName: string): { ownerKey: string; charName: string } | null { try { const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { - if (ownerKey === usermapKey) continue; + if (ownerKey === userKey) continue; const char = data.characters?.find( - (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey) + (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey) ); if (char) return { ownerKey, charName: char.name }; } @@ -29,25 +29,25 @@ export async function handleCharSetActive(interaction: ChatInputCommandInteracti const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); // Try own characters first - const set = setActiveCharacter(usermapKey, charName); + const set = setActiveCharacter(userKey, charName); if (set) return void replyAndDelete(interaction, `✅ **${charName}** is now your active character.`); // Fall back to shared characters - const shared = findSharedChar(usermapKey, charName); + const shared = findSharedChar(userKey, charName); if (shared) { - setSessionBorrow(usermapKey, shared.ownerKey, shared.charName); + setSessionBorrow(userKey, shared.ownerKey, shared.charName); return void replyAndDelete(interaction, `✅ **${charName}** (shared by **${shared.ownerKey}**) set as active for this session.`); } diff --git a/src/subcommands/char/setNation.ts b/src/subcommands/char/setNation.ts index fdbb447..41243a1 100644 --- a/src/subcommands/char/setNation.ts +++ b/src/subcommands/char/setNation.ts @@ -12,21 +12,21 @@ export async function handleCharSetNation(interaction: ChatInputCommandInteracti const nation = interaction.options.getString("nation", true) as Nation; const charName = interaction.options.getString("char_name"); // optional, defaults to active - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const targetName = charName ?? getActiveCharacter(usermapKey)?.name; + const targetName = charName ?? getActiveCharacter(userKey)?.name; if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); - const set = setCharacterNation(usermapKey, targetName, nation); + const set = setCharacterNation(userKey, targetName, nation); if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); return replyAndDelete(interaction, `✅ **«${targetName}»** nation set to **${nation}**.`); diff --git a/src/subcommands/char/setStats.ts b/src/subcommands/char/setStats.ts index 57a8038..36f3c23 100644 --- a/src/subcommands/char/setStats.ts +++ b/src/subcommands/char/setStats.ts @@ -13,21 +13,21 @@ export async function handleCharSetStats(interaction: ChatInputCommandInteractio const def = interaction.options.getInteger("def") ?? undefined; const heal = interaction.options.getInteger("heal") ?? undefined; - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return replyAndDelete(interaction, "❌ Only officers can manage other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return replyAndDelete(interaction, "❌ You are not registered in the system."); - const targetName = charName ?? getActiveCharacter(usermapKey)?.name; + const targetName = charName ?? getActiveCharacter(userKey)?.name; if (!targetName) return replyAndDelete(interaction, "❌ No active character found. Specify a character name."); - const set = setCharacterStats(usermapKey, targetName, { atk, def, heal }); + const set = setCharacterStats(userKey, targetName, { atk, def, heal }); if (!set) return replyAndDelete(interaction, `❌ No character named **${targetName}** found.`); return replyAndDelete(interaction, `✅ Stats updated for **«${targetName}»**.`); diff --git a/src/subcommands/char/share.ts b/src/subcommands/char/share.ts index ba72a0e..f6f7747 100644 --- a/src/subcommands/char/share.ts +++ b/src/subcommands/char/share.ts @@ -5,6 +5,7 @@ import { getCharacterByName } from "../../systems/characters"; import { replyAndDelete } from "../../utils"; import fs from "fs"; import path from "path"; +import { clearPersistentPreference } from "../../systems/borrow"; const CHARS_PATH = path.join(__dirname, "../../../data/characters.json"); @@ -29,7 +30,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction): return void replyAndDelete(interaction, "❌ Only officers can share other players' characters."); } - const ownerKey = ownerArg ?? user.usermapKey; + const ownerKey = ownerArg ?? user.userKey; if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const char = getCharacterByName(ownerKey, charName); @@ -48,7 +49,7 @@ export async function handleCharShare(interaction: ChatInputCommandInteraction): charEntry.sharedWith.push(targetKey); saveCharacters(raw); - return void replyAndDelete(interaction, `✅ **«${charName}»** is now permanently shared with **${targetKey}**.`); + return void replyAndDelete(interaction, `✅ **${charName}** is now permanently shared with **${targetKey}**.`); } export async function handleCharUnshare(interaction: ChatInputCommandInteraction): Promise { @@ -64,7 +65,7 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction return void replyAndDelete(interaction, "❌ Only officers can modify other players' character shares."); } - const ownerKey = ownerArg ?? user.usermapKey; + const ownerKey = ownerArg ?? user.userKey; if (!ownerKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const raw = loadRawChars(); @@ -78,5 +79,8 @@ export async function handleCharUnshare(interaction: ChatInputCommandInteraction charEntry.sharedWith = charEntry.sharedWith.filter((k: string) => k !== targetKey); saveCharacters(raw); + // Clear persistent preference if the borrower was using this char + clearPersistentPreference(targetKey); + return void replyAndDelete(interaction, `✅ **${targetKey}**'s access to **«${charName}»** has been revoked.`); } \ No newline at end of file diff --git a/src/subcommands/impersonate.ts b/src/subcommands/impersonate.ts new file mode 100644 index 0000000..21f11a2 --- /dev/null +++ b/src/subcommands/impersonate.ts @@ -0,0 +1,126 @@ +import { + ChatInputCommandInteraction, + ButtonInteraction, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + EmbedBuilder, +} from "discord.js"; +import { cfg } from "@systems/config"; +import { hasOfficerRole } from "@systems/users"; +import { getRegisteredUsers, setImpersonation, clearImpersonation, getImpersonation } from "@systems/impersonate"; +import { replyAndDelete } from "@utils"; + +const PAGE_SIZE = 5; // users per page (1 button each + nav row) + +function buildImpersonateEmbed(users: { userKey: string; aliases: string[] }[], page: number, currentImpersonation: string | null): EmbedBuilder { + const start = page * PAGE_SIZE; + const pageUsers = users.slice(start, start + PAGE_SIZE); + + const lines = pageUsers.map((u, i) => { + const display = u.aliases[0] ?? u.userKey; + const current = u.userKey === currentImpersonation ? " ← *active*" : ""; + return `${start + i + 1}. **${display}** (${u.userKey})${current}`; + }); + + return new EmbedBuilder() + .setTitle("🎭 Impersonate User") + .setDescription( + (currentImpersonation + ? `Currently impersonating: **${currentImpersonation}**\n\n` + : "Not impersonating anyone.\n\n") + + lines.join("\n") + ) + .setColor(0x5865f2) + .setFooter({ text: `Page ${page + 1} of ${Math.ceil(users.length / PAGE_SIZE)}` }); +} + +function buildImpersonateButtons( + users: { userKey: string; aliases: string[] }[], + page: number, + realDiscordId: string +): ActionRowBuilder[] { + const start = page * PAGE_SIZE; + const pageUsers = users.slice(start, start + PAGE_SIZE); + const hasNext = users.length > (page + 1) * PAGE_SIZE; + const hasPrev = page > 0; + const rows: ActionRowBuilder[] = []; + + // One button per user on this page + const userButtons = pageUsers.map((u) => + new ButtonBuilder() + .setCustomId(`impersonate_set:${u.userKey}:${page}`) + .setLabel(`${u.aliases[0] ?? u.userKey}`) + .setStyle(ButtonStyle.Primary) + ); + if (userButtons.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...userButtons)); + } + + // Nav + release row + const navBtns: ButtonBuilder[] = []; + if (hasPrev) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page - 1}`).setLabel("← Prev").setStyle(ButtonStyle.Secondary)); + if (hasNext) navBtns.push(new ButtonBuilder().setCustomId(`impersonate_page:${page + 1}`).setLabel("Next →").setStyle(ButtonStyle.Secondary)); + navBtns.push(new ButtonBuilder().setCustomId("impersonate_release").setLabel("🚫 Release").setStyle(ButtonStyle.Danger)); + rows.push(new ActionRowBuilder().addComponents(...navBtns)); + + return rows; +} + +// Slash command handler +export async function handleImpersonate(interaction: ChatInputCommandInteraction): Promise { + const member = await interaction.guild!.members.fetch(interaction.user.id); + if (!hasOfficerRole(member, cfg("officerRoles"))) { + return void replyAndDelete(interaction, "❌ You don't have permission to use this command."); + } + + const users = getRegisteredUsers(); + if (users.length === 0) return void replyAndDelete(interaction, "❌ No registered users found in usermap.json."); + + const current = getImpersonation(interaction.user.id); + const embed = buildImpersonateEmbed(users, 0, current); + const buttons = buildImpersonateButtons(users, 0, interaction.user.id); + + await interaction.reply({ embeds: [embed], components: buttons, ephemeral: true }); +} + +// Button handler +export async function handleImpersonateButton(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + const realId = interaction.user.id; + + if (customId.startsWith("impersonate_set:")) { + const parts = customId.split(":"); + const userKey = parts[1]; + const page = parseInt(parts[2] ?? "0"); + + setImpersonation(realId, userKey); + const users = getRegisteredUsers(); + const embed = buildImpersonateEmbed(users, page, userKey); + const buttons = buildImpersonateButtons(users, page, realId); + + await interaction.update({ embeds: [embed], components: buttons }); + return; + } + + if (customId.startsWith("impersonate_page:")) { + const page = parseInt(customId.split(":")[1]); + const current = getImpersonation(realId); + const users = getRegisteredUsers(); + const embed = buildImpersonateEmbed(users, page, current); + const buttons = buildImpersonateButtons(users, page, realId); + + await interaction.update({ embeds: [embed], components: buttons }); + return; + } + + if (customId === "impersonate_release") { + clearImpersonation(realId); + const users = getRegisteredUsers(); + const embed = buildImpersonateEmbed(users, 0, null); + const buttons = buildImpersonateButtons(users, 0, realId); + + await interaction.update({ embeds: [embed], components: buttons }); + return; + } +} \ No newline at end of file diff --git a/src/subcommands/poll/inject.ts b/src/subcommands/poll/inject.ts index 9606327..eee0625 100644 --- a/src/subcommands/poll/inject.ts +++ b/src/subcommands/poll/inject.ts @@ -1,17 +1,18 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; import { cfg } from "../../systems/config"; import { polls, updatePollMessage } from "../../systems/poll"; -import { getActiveCharacter } from "../../systems/characters"; -import { resolveNation } from "../../systems/nations"; +import { getEffectiveCharacter } from "../../systems/borrow"; import { nowFormatted, resolveMessage } from "../../systems/messages"; import { replyAndDelete } from "../../utils"; import { VoteEntry } from "../../types"; export async function handleInject(interaction: ChatInputCommandInteraction): Promise { - const usermapKey = interaction.options.getString("name", true); + const userKey = interaction.options.getString("name", true); const voteType = interaction.options.getString("vote_type", true) as "yes" | "no"; + console.log("[inject] called"); const slot = [...polls.keys()][0]; + console.log("[inject] slot:", slot); if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); const state = polls.get(slot)!; @@ -19,23 +20,24 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr return void replyAndDelete(interaction, "❌ Poll is locked or confirmed."); } - const char = getActiveCharacter(usermapKey); - console.log(`[DEBUG inject] usermapKey=${usermapKey} char=${JSON.stringify(char)}`); - if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${usermapKey}**.`); + const { char, borrowedFrom } = getEffectiveCharacter(userKey); + console.log(`[DEBUG inject] userKey=${userKey} char=${JSON.stringify(char)} borrowedFrom=${JSON.stringify(borrowedFrom)}`); + if (!char) return void replyAndDelete(interaction, `❌ No active character found for **${userKey}**.`); - // Use a synthetic userId based on usermapKey to avoid collisions - const syntheticId = `injected:${usermapKey}`; + // Use a synthetic userId based on userKey to avoid collisions + const syntheticId = `injected:${userKey}`; const now = nowFormatted(); - const publicMsg = resolveMessage("public", voteType, 1, usermapKey, null, null); + const publicMsg = resolveMessage("public", voteType, 1, userKey, null, null); - const entry: VoteEntry = { - usermapKey, +const entry: VoteEntry = { + userKey, displayName: char.name, characterName: char.name, characterClass: char.class, characterLevel: char.level, characterNation: char.nation, + borrowedFrom: borrowedFrom ?? undefined, votedAt: now, publicMessage: publicMsg ?? undefined, }; @@ -50,31 +52,31 @@ export async function handleInject(interaction: ChatInputCommandInteraction): Pr const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot); - return void replyAndDelete(interaction, `✅ Injected **${usermapKey}** as **${voteType}**.`); + return void replyAndDelete(interaction, `✅ Injected **${userKey}** as **${voteType}**.`); } export async function handleRemoveVote(interaction: ChatInputCommandInteraction): Promise { - const usermapKey = interaction.options.getString("name", true); + const userKey = interaction.options.getString("name", true); const slot = [...polls.keys()][0]; if (slot === undefined) return void replyAndDelete(interaction, "❌ No active poll found."); const state = polls.get(slot)!; - const syntheticId = `injected:${usermapKey}`; + const syntheticId = `injected:${userKey}`; - // Also try removing real votes by scanning for usermapKey + // Also try removing real votes by scanning for userKey let removed = false; for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.usermapKey === usermapKey || id === syntheticId) { + if (entry.userKey === userKey || id === syntheticId) { state.yes.delete(id); state.no.delete(id); removed = true; } } - if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${usermapKey}**.`); + if (!removed) return void replyAndDelete(interaction, `❌ No vote found for **${userKey}**.`); const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; await updatePollMessage(channel, slot); - return void replyAndDelete(interaction, `✅ Vote removed for **${usermapKey}**.`); + return void replyAndDelete(interaction, `✅ Vote removed for **${userKey}**.`); } \ No newline at end of file diff --git a/src/subcommands/poll/seed.ts b/src/subcommands/poll/seed.ts index 9bdc5ae..955bd4b 100644 --- a/src/subcommands/poll/seed.ts +++ b/src/subcommands/poll/seed.ts @@ -29,18 +29,18 @@ export async function handleSeed(interaction: ChatInputCommandInteraction): Prom let skipped = 0; for (const [discordUsername, entry] of Object.entries(usermap)) { - const usermapKey = typeof entry === "string" ? entry : entry.file; - const { char, borrowedFrom } = getEffectiveCharacter(usermapKey); + const userKey = typeof entry === "string" ? entry : entry.file; + const { char, borrowedFrom } = getEffectiveCharacter(userKey); if (!char) { skipped++; continue; } - const syntheticId = `injected:${usermapKey}`; + const syntheticId = `injected:${userKey}`; if (state.yes.has(syntheticId) || state.no.has(syntheticId)) { skipped++; continue; } const publicMsg = resolveMessage("public", "yes", 1, discordUsername, null, null); const voteEntry: VoteEntry = { - usermapKey, + userKey, displayName: char.name, characterName: char.name, characterClass: char.class, diff --git a/src/subcommands/rank/get.ts b/src/subcommands/rank/get.ts index 604132d..b446497 100644 --- a/src/subcommands/rank/get.ts +++ b/src/subcommands/rank/get.ts @@ -13,29 +13,29 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P return void replyAndDelete(interaction, "❌ Only officers can view other players' ranks."); } - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); const week = getCurrentWeek(); const goal = cfg("wRankGoal"); const weekKey = getWeekKey(); for (const nation of ["capella", "procyon"] as const) { - const entry = week.entries[nation].find((e) => e.usermapKey === usermapKey); + const entry = week.entries[nation].find((e) => e.userKey === userKey); if (!entry) continue; const isDone = entry.tgCount >= goal; const delta = entry.previousRank !== undefined ? entry.currentRank - entry.previousRank : 0; const deltaStr = delta < 0 ? ` (↑${Math.abs(delta)})` : delta > 0 ? ` (↓${delta})` : ""; const bringer = getBringer(entry.nation); - const isBringer = bringer === usermapKey && isDone; + const isBringer = bringer === userKey && isDone; const lines = [ `**${entry.characterName}** · ${entry.nation}`, @@ -48,5 +48,5 @@ export async function handleRankGet(interaction: ChatInputCommandInteraction): P return void replyAndDelete(interaction, lines); } - return void replyAndDelete(interaction, `❌ No rank found for **${usermapKey}** this week.`); + return void replyAndDelete(interaction, `❌ No rank found for **${userKey}** this week.`); } diff --git a/src/subcommands/rank/post.ts b/src/subcommands/rank/post.ts index 8418540..c1050be 100644 --- a/src/subcommands/rank/post.ts +++ b/src/subcommands/rank/post.ts @@ -16,7 +16,7 @@ export async function handleRankPost(interaction: ChatInputCommandInteraction): const isDone = e.tgCount >= goal; const delta = e.previousRank !== undefined ? e.currentRank - e.previousRank : 0; const deltaStr = delta < 0 ? ` ↑${Math.abs(delta)}` : delta > 0 ? ` ↓${delta}` : ""; - const bringerStr = bringer === e.usermapKey && isDone + const bringerStr = bringer === e.userKey && isDone ? ` · ${nation === "capella" ? "Luminous Bringer" : "Storm Bringer"}` : ""; return `${isDone ? "🟡" : "⬜"}${e.currentRank}${deltaStr} «${e.characterName}» — ${e.weeklyPoints} pts (${e.tgCount}/${goal}${bringerStr})`; diff --git a/src/subcommands/score/get.ts b/src/subcommands/score/get.ts index 1b82559..f91aa98 100644 --- a/src/subcommands/score/get.ts +++ b/src/subcommands/score/get.ts @@ -3,6 +3,7 @@ import { cfg } from "../../systems/config"; import { resolveUser, hasOfficerRole } from "../../systems/users"; import { normalizeSlot, detectSlot } from "../../systems/scores"; import { loadResult, todayString } from "../../systems/history"; +import { getEmoji } from "../../systems/emojis"; import { replyAndDelete } from "../../utils"; export async function handleScoreGet(interaction: ChatInputCommandInteraction): Promise { @@ -12,41 +13,51 @@ export async function handleScoreGet(interaction: ChatInputCommandInteraction): const slotArg = interaction.options.getString("slot"); if (nameArg && !isOfficer) { - return void replyAndDelete(interaction, "❌ Only officers can view other players' scores."); + return void replyAndDelete(interaction, "❌ Only officers can view other players' scores.", true); } - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system.", true); let slot: number | null = null; if (slotArg) { slot = normalizeSlot(slotArg); - if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); + if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`, true); } else { - slot = detectSlot() ?? cfg("slots")[0]?.tgHour ?? 20; + slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; } const result = loadResult(todayString(), slot); - if (!result) return void replyAndDelete(interaction, `❌ No result found for ${slot}:00 TG today.`); + if (!result) return void replyAndDelete(interaction, `❌ No result found for **${slot}:00** TG today.`, true); - const score = result.scores.find((s) => s.usermapKey === usermapKey); - if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${usermapKey}** in the ${slot}:00 TG.`); + // Find score — check both direct ownership and borrowed (playedBy) + const score = result.scores.find( + (s) => s.userKey === userKey || (s as any).playedBy === userKey + ); + + if (!score) return void replyAndDelete(interaction, `❌ No score submitted for **${userKey}** in the **${slot}:00** TG.`, true); + + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const playedBy = (score as any).playedBy && (score as any).playedBy !== score.userKey + ? `\n*(played by ${(score as any).playedBy})*` + : ""; const lines = [ - `**${score.characterName}** (${score.class} · ${score.nation})`, - `**Points:** ${score.pts}`, - score.atk !== undefined ? `**ATK:** ${score.atk}` : null, - score.def !== undefined ? `**DEF:** ${score.def}` : null, - score.heal !== undefined ? `**HEAL:** ${score.heal}` : null, - `**Submitted:** ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}`, + `**${score.characterName}** (${score.class} · ${score.nation})${playedBy}`, + `${scoreEmoji} **${score.pts}** pts`, + score.atk !== undefined ? `ATK: ${score.atk}` : null, + score.def !== undefined ? `DEF: ${score.def}` : null, + score.heal !== undefined ? `HEAL: ${score.heal}` : null, + `*Submitted at ${new Date(score.submittedAt).toLocaleTimeString("en-GB", { timeZone: process.env.TZ ?? "Etc/GMT-2" })}*`, ].filter(Boolean).join("\n"); - return void replyAndDelete(interaction, lines); -} + return void replyAndDelete(interaction, lines, true); +} \ No newline at end of file diff --git a/src/subcommands/score/set.ts b/src/subcommands/score/set.ts index b813206..caf271d 100644 --- a/src/subcommands/score/set.ts +++ b/src/subcommands/score/set.ts @@ -1,57 +1,75 @@ import { ChatInputCommandInteraction } from "discord.js"; -import { cfg } from "../../systems/config"; -import { resolveUser, hasOfficerRole } from "../../systems/users"; -import { submitScore, detectSlot, normalizeSlot } from "../../systems/scores"; -import { getActiveCharacter } from "../../systems/characters"; -import { resolveNation } from "../../systems/nations"; -import { replyAndDelete } from "../../utils"; +import { cfg } from "@systems/config"; +import { resolveUser, hasOfficerRole } from "@systems/users"; +import { submitScore, detectSlot, normalizeSlot } from "@systems/scores"; +import { getEffectiveCharacter } from "@systems/borrow"; +import { replyAndDelete } from "@utils"; +import { getEmoji } from "@systems/emojis"; export async function handleScoreSet(interaction: ChatInputCommandInteraction): Promise { - const member = await interaction.guild!.members.fetch(interaction.user.id); - const isOfficer = hasOfficerRole(member, cfg("officerRoles")); - - const nameArg = interaction.options.getString("name"); - const ptsArg = interaction.options.getInteger("pts", true); - const slotArg = interaction.options.getString("slot"); - - // Officers can specify a name, players cannot - let usermapKey: string | null; - let targetMember = member; + const member = await interaction.guild!.members.fetch(interaction.user.id); + const isOfficer = hasOfficerRole(member, cfg("officerRoles")); + const nameArg = interaction.options.getString("name"); + const ptsArg = interaction.options.getInteger("pts", true); + const slotArg = interaction.options.getString("slot"); + const kills = interaction.options.getInteger("k") ?? undefined; + const deaths = interaction.options.getInteger("d") ?? undefined; + const k = interaction.options.getInteger("k") ?? undefined; + const d = interaction.options.getInteger("d") ?? undefined; + const atk = interaction.options.getInteger("atk") ?? undefined; + const def = interaction.options.getInteger("def") ?? undefined; + const heal = interaction.options.getInteger("heal") ?? undefined; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can submit scores for other players."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); - const char = getActiveCharacter(usermapKey); + const { char, borrowedFrom } = getEffectiveCharacter(userKey); if (!char) return void replyAndDelete(interaction, "❌ No active character found. Use `/tg char set-active` first."); - // Resolve slot let slot: number | null = null; if (slotArg) { slot = normalizeSlot(slotArg); if (slot === null) return void replyAndDelete(interaction, `❌ Could not parse slot "${slotArg}".`); } else { - slot = detectSlot(); - if (slot === null) { - return void replyAndDelete(interaction, "❌ No active score window detected. Specify a slot explicitly."); - } + slot = detectSlot() ?? cfg("slots").find((s) => s.active)?.tgHour ?? 20; } await submitScore({ - usermapKey, + userKey: borrowedFrom ?? userKey, + playedBy: borrowedFrom ? userKey : undefined, characterName: char.name, cls: char.class, nation: char.nation, pts: ptsArg, + k, + d, slot, + atk, + def, + heal, submittedByOfficer: isOfficer && !!nameArg, }); - return void replyAndDelete(interaction, `✅ Score of **${ptsArg}** submitted for **${char.name}** (${slot}:00 TG).`); -} + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const borrowNote = borrowedFrom ? ` *(borrowed from ${borrowedFrom})*` : ""; + const kdNote = kills !== undefined && deaths !== undefined ? `\n${kdEmoji} ${kills}/${deaths}` : ""; + const statsNote = [ + atk !== undefined ? `ATK: ${atk}` : null, + def !== undefined ? `DEF: ${def}` : null, + heal !== undefined ? `HEAL: ${heal}` : null, + ].filter(Boolean).join(" · "); + + return void replyAndDelete(interaction, + `✅ ${scoreEmoji} **${ptsArg}** submitted for **${char.name}**${borrowNote} (${slot}:00 TG)${kdNote}${statsNote ? `\n${statsNote}` : ""}`, + true + ); +} \ No newline at end of file diff --git a/src/subcommands/switch.ts b/src/subcommands/switch.ts index f7192e4..43a6de6 100644 --- a/src/subcommands/switch.ts +++ b/src/subcommands/switch.ts @@ -1,22 +1,30 @@ import { ChatInputCommandInteraction, TextChannel } from "discord.js"; -import { cfg } from "../systems/config"; -import { resolveUser, hasOfficerRole } from "../systems/users"; -import { setActiveCharacter, getActiveCharacter, getCharacterByName } from "../systems/characters"; -import { setSessionBorrow, getSessionBorrow } from "../systems/borrow"; -import { polls, updatePollMessage } from "../systems/poll"; -import { replyAndDelete } from "../utils"; +import { cfg } from "@systems/config"; +import { resolveUser, hasOfficerRole } from "@systems/users"; +import { setActiveCharacter, getActiveCharacter, getCharacterByName, getCharacters } from "@systems/characters"; +import { + getEffectiveCharacter, + setSessionBorrow, + setPersistentPreference, + clearPersistentPreference, + clearSessionBorrowForUser, +} from "@systems/borrow"; +import { polls, updatePollMessage } from "@systems/poll"; +import { getClassEmoji } from "@systems/emojis"; +import { replyAndDelete } from "@src/utils"; +import { format } from "@format"; import fs from "fs"; import path from "path"; const CHARS_PATH = path.join(__dirname, "../../data/characters.json"); -function findSharedChar(usermapKey: string, charName: string): { ownerKey: string; char: any } | null { +function findSharedChar(userKey: string, charName: string): { ownerKey: string; char: any } | null { try { const chars = JSON.parse(fs.readFileSync(CHARS_PATH, "utf8")); for (const [ownerKey, data] of Object.entries(chars) as [string, any][]) { - if (ownerKey === usermapKey) continue; + if (ownerKey === userKey) continue; const char = data.characters?.find( - (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(usermapKey) + (c: any) => c.name.toLowerCase() === charName.toLowerCase() && c.sharedWith?.includes(userKey) ); if (char) return { ownerKey, char }; } @@ -24,10 +32,9 @@ function findSharedChar(usermapKey: string, charName: string): { ownerKey: strin return null; } -// Reverse-lookup: find Discord userId for a usermapKey from current poll voters -function findUserIdInPoll(state: any, usermapKey: string): string | null { +function findVoteIdInPoll(state: any, userKey: string): string | null { for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { - if (entry.usermapKey === usermapKey) return id; + if (entry.userKey === userKey) return id; } return null; } @@ -38,49 +45,85 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr const nameArg = interaction.options.getString("name"); const charName = interaction.options.getString("char_name", true); - let usermapKey: string | null; + let userKey: string | null; if (nameArg) { if (!isOfficer) return void replyAndDelete(interaction, "❌ Only officers can switch other players' characters."); - usermapKey = nameArg; + userKey = nameArg; } else { const user = await resolveUser(member); - usermapKey = user.usermapKey; + userKey = user.userKey; } - if (!usermapKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + if (!userKey) return void replyAndDelete(interaction, "❌ You are not registered in the system."); + // Resolve the target character without switching yet let resolvedChar: any = null; let borrowedFrom: string | null = null; - // Try own characters first - const set = setActiveCharacter(usermapKey, charName); - if (set) { - resolvedChar = getActiveCharacter(usermapKey); + const ownChar = getCharacterByName(userKey, charName); + if (ownChar) { + resolvedChar = ownChar; } else { - // Fall back to shared characters - const shared = findSharedChar(usermapKey, charName); + const shared = findSharedChar(userKey, charName); if (shared) { - setSessionBorrow(usermapKey, shared.ownerKey, shared.char.name); - resolvedChar = shared.char; - borrowedFrom = shared.ownerKey; - console.log(`[borrow] Session borrow set: ${usermapKey} → ${shared.ownerKey}:${shared.char.name}`); - console.log(`[borrow] Current borrows:`, getSessionBorrow(usermapKey)); + resolvedChar = shared.char; + borrowedFrom = shared.ownerKey; } } if (!resolvedChar) return void replyAndDelete(interaction, `❌ No character named **${charName}** found.`); - // Update poll embed if user has voted + // If already active — just show current state without switching + const current = getEffectiveCharacter(userKey); + if (current.char?.name === resolvedChar.name) { + const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; + const borrowNote = current.borrowedFrom ? ` *(shared by ${current.borrowedFrom})*` : ""; + return void replyAndDelete(interaction, `${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); + } + + // Check if target character is already in the active poll by another player const slot = [...polls.keys()][0]; if (slot !== undefined) { - const state = polls.get(slot)!; - const userId = nameArg - ? findUserIdInPoll(state, usermapKey) - : interaction.user.id; + const state = polls.get(slot)!; + for (const [id, entry] of state.yes.entries()) { + const isOwnEntry = id === interaction.user.id || id === `impersonated:${userKey}`; + if (!isOwnEntry && entry.characterName === resolvedChar.name && entry.userKey !== userKey) { + const slotHour = state.slot; + const charDisplay = format.char(resolvedChar); + const isOwner = getCharacters(userKey).some((c) => c.name === resolvedChar.name); + if (isOwner) { + return void replyAndDelete(interaction, + `⚠️ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Vote with your character and use the reclaim button that appears.`, + true + ); + } + return void replyAndDelete(interaction, + `❌ ${charDisplay} is already scheduled for TG at ${slotHour}:00. Pick a different character.`, + true + ); + } + } + } - if (userId && (state.yes.has(userId) || state.no.has(userId))) { + // Now actually switch + if (borrowedFrom) { + setSessionBorrow(userKey, borrowedFrom, resolvedChar.name); + setPersistentPreference(userKey, borrowedFrom, resolvedChar.name); + } else { + setActiveCharacter(userKey, charName); + clearPersistentPreference(userKey); + clearSessionBorrowForUser(userKey); + resolvedChar = getActiveCharacter(userKey); + } + + // Update poll embed if user has already voted + if (slot !== undefined) { + const state = polls.get(slot)!; + const voteId = findVoteIdInPoll(state, userKey); + + if (voteId && (state.yes.has(voteId) || state.no.has(voteId))) { const updateEntry = (map: Map) => { - const entry = map.get(userId); + const entry = map.get(voteId); if (entry) { entry.characterName = resolvedChar.name; entry.characterClass = resolvedChar.class; @@ -97,6 +140,7 @@ export async function handleSwitch(interaction: ChatInputCommandInteraction): Pr } } - const borrowNote = borrowedFrom ? ` (shared by **${borrowedFrom}**)` : ""; - return void replyAndDelete(interaction, `✅ Switched to **${charName}**${borrowNote}.`); + const classEmoji = getClassEmoji(resolvedChar.class) || resolvedChar.class; + const borrowNote = borrowedFrom ? ` *(shared by ${borrowedFrom})*` : ""; + return void replyAndDelete(interaction, `🔄 ${classEmoji} ${resolvedChar.level} ${resolvedChar.name}${borrowNote}`, true); } \ No newline at end of file diff --git a/src/systems/borrow.ts b/src/systems/borrow.ts index 30c790f..73206a6 100644 --- a/src/systems/borrow.ts +++ b/src/systems/borrow.ts @@ -1,15 +1,44 @@ import { Client, TextChannel, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; +import fs from "fs"; +import path from "path"; import { BorrowRequest } from "../types"; import { cfg } from "./config"; import { getCharacterByName } from "./characters"; -// Active borrow requests (pending accept/decline) -const pendingRequests: Map = new Map(); // key: `${ownerKey}:${requesterKey}` +const PREFS_PATH = path.join(__dirname, "../../data/sessionPreferences.json"); -// Session borrows: usermapKey → { ownerKey, charName } — reset on poll start +// ─── Persistent preferences ─────────────────────────────────────────────────── +let _prefs: Record = {}; + +function loadPrefs(): void { + try { _prefs = JSON.parse(fs.readFileSync(PREFS_PATH, "utf8")); } + catch { _prefs = {}; } +} + +function savePrefs(): void { + try { fs.writeFileSync(PREFS_PATH, JSON.stringify(_prefs, null, 2)); } + catch (err) { console.error("Failed to save sessionPreferences.json:", err); } +} + +loadPrefs(); + +export function setPersistentPreference(userKey: string, ownerKey: string, charName: string): void { + _prefs[userKey] = { ownerKey, charName }; + savePrefs(); +} + +export function clearPersistentPreference(userKey: string): void { + delete _prefs[userKey]; + savePrefs(); +} + +export function getPersistentPreference(userKey: string): { ownerKey: string; charName: string } | null { + return _prefs[userKey] ?? null; +} + +// ─── Active borrow requests ─────────────────────────────────────────────────── +const pendingRequests: Map = new Map(); const sessionBorrows: Map = new Map(); - -// DM message IDs for updating borrow request messages const borrowDmMessages: Map = new Map(); function requestKey(ownerKey: string, requesterKey: string): string { @@ -31,9 +60,7 @@ export function getAllPendingForOwner(ownerKey: string): BorrowRequest[] { export function addPendingRequest(request: BorrowRequest): void { const key = requestKey(request.ownerKey, request.requesterKey); const expiry = cfg("borrowRequestExpiryMs" as any) ?? 0; - pendingRequests.set(key, request); - if (expiry > 0) { setTimeout(() => { if (pendingRequests.get(key)?.requestedAt === request.requestedAt) { @@ -56,7 +83,7 @@ export function getDmMessage(ownerKey: string, requesterKey: string): { channelI return borrowDmMessages.get(requestKey(ownerKey, requesterKey)) ?? null; } -// Session borrow management +// ─── Session borrows ────────────────────────────────────────────────────────── export function setSessionBorrow(requesterKey: string, ownerKey: string, charName: string): void { sessionBorrows.set(requesterKey, { ownerKey, charName }); } @@ -70,22 +97,20 @@ export function clearSessionBorrows(): void { borrowDmMessages.clear(); } -// Check if a user can use a character (owns it or has share/borrow access) export function canUseCharacter(requesterKey: string, ownerKey: string, charName: string): boolean { if (requesterKey === ownerKey) return true; - - // Check persistent share const char = getCharacterByName(ownerKey, charName); if (char?.sharedWith?.includes(requesterKey)) return true; - - // Check session borrow const borrow = getSessionBorrow(requesterKey); if (borrow && borrow.ownerKey === ownerKey && borrow.charName.toLowerCase() === charName.toLowerCase()) return true; - return false; } -// Send borrow request DM to owner, fall back to poll channel ephemeral +export function clearSessionBorrowForUser(userKey: string): void { + sessionBorrows.delete(userKey); +} + +// ─── DM notifications ───────────────────────────────────────────────────────── export async function sendBorrowRequestDM( client: Client, ownerDiscordId: string, @@ -117,7 +142,6 @@ export async function sendBorrowRequestDM( const msg = await dm.send({ content, components: [row] }); storeDmMessage(ownerKey, requesterKey, dm.id, msg.id); } catch { - // DM failed — fall back to poll channel ephemeral if (fallbackChannel) { await fallbackChannel.send({ content: `<@${ownerDiscordId}> ${content}\nUse \`/tg char accept ${requesterKey}\` or \`/tg char decline ${requesterKey}\`.`, @@ -126,7 +150,6 @@ export async function sendBorrowRequestDM( } } -// Update DM after accept/decline to disable buttons export async function updateBorrowDM( client: Client, ownerKey: string, @@ -140,19 +163,30 @@ export async function updateBorrowDM( const message = await channel.messages.fetch(dm.messageId); const status = accepted ? "✅ Accepted" : "❌ Declined"; await message.edit({ content: `${message.content}\n\n*${status}*`, components: [] }); - } catch { - // DM may have been deleted, ignore - } + } catch {} } -// Returns the effective active character for a user — session borrow takes priority over own active char -export function getEffectiveCharacter(usermapKey: string): { char: any; borrowedFrom: string | null } { - const { getActiveCharacter, getCharacterByName } = require("./characters"); - const borrow = getSessionBorrow(usermapKey); +// ─── Effective character resolution ────────────────────────────────────────── +export function getEffectiveCharacter(userKey: string): { char: any; borrowedFrom: string | null } { + const { getActiveCharacter, getCharacterByName: getChar } = require("./characters"); + + // 1. Session borrow (temporary, resets on poll start) + const borrow = getSessionBorrow(userKey); if (borrow) { - const char = getCharacterByName(borrow.ownerKey, borrow.charName); + const char = getChar(borrow.ownerKey, borrow.charName); if (char) return { char, borrowedFrom: borrow.ownerKey }; } - const char = getActiveCharacter(usermapKey); + + // 2. Persistent preference (survives restarts and poll resets) + const pref = getPersistentPreference(userKey); + console.log(`[getEffectiveCharacter] userKey=${userKey} sessionBorrow=${JSON.stringify(borrow)} pref=${JSON.stringify(pref)}`); + if (pref) { + const char = getChar(pref.ownerKey, pref.charName); + if (char) return { char, borrowedFrom: pref.ownerKey }; + clearPersistentPreference(userKey); + } + + // 3. Own active character + const char = getActiveCharacter(userKey); return { char: char ?? null, borrowedFrom: null }; } \ No newline at end of file diff --git a/src/systems/characters.ts b/src/systems/characters.ts index 207f430..14a8645 100644 --- a/src/systems/characters.ts +++ b/src/systems/characters.ts @@ -23,52 +23,52 @@ function saveAccounts(): void { fs.writeFileSync(ACCOUNTS_PATH, JSON.stringify(_accounts, null, 2)); } -export function getCharacters(usermapKey: string): Character[] { - return _chars[usermapKey]?.characters ?? []; +export function getCharacters(userKey: string): Character[] { + return _chars[userKey]?.characters ?? []; } -export function getActiveCharacter(usermapKey: string): Character | null { - return getCharacters(usermapKey).find((c) => c.active) ?? null; +export function getActiveCharacter(userKey: string): Character | null { + return getCharacters(userKey).find((c) => c.active) ?? null; } -export function getCharacterByName(usermapKey: string, name: string): Character | null { - return getCharacters(usermapKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null; +export function getCharacterByName(userKey: string, name: string): Character | null { + return getCharacters(userKey).find((c) => c.name.toLowerCase() === name.toLowerCase()) ?? null; } -export function getCharacterByClass(usermapKey: string, cls: ClassKey): Character | null { +export function getCharacterByClass(userKey: string, cls: ClassKey): Character | null { // Returns the active character of that class, or first found - const chars = getCharacters(usermapKey).filter((c) => c.class === cls); + const chars = getCharacters(userKey).filter((c) => c.class === cls); return chars.find((c) => c.active) ?? chars[0] ?? null; } -export function addCharacter(usermapKey: string, char: Omit): boolean { - if (!_chars[usermapKey]) _chars[usermapKey] = { characters: [] }; - const exists = _chars[usermapKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase()); +export function addCharacter(userKey: string, char: Omit): boolean { + if (!_chars[userKey]) _chars[userKey] = { characters: [] }; + const exists = _chars[userKey].characters.some((c) => c.name.toLowerCase() === char.name.toLowerCase()); if (exists) return false; // If no active character, set this one as active - const hasActive = _chars[usermapKey].characters.some((c) => c.active); - _chars[usermapKey].characters.push({ ...char, active: !hasActive }); + const hasActive = _chars[userKey].characters.some((c) => c.active); + _chars[userKey].characters.push({ ...char, active: !hasActive }); saveCharacters(); return true; } -export function removeCharacter(usermapKey: string, name: string): boolean { - if (!_chars[usermapKey]) return false; - const before = _chars[usermapKey].characters.length; - _chars[usermapKey].characters = _chars[usermapKey].characters.filter( +export function removeCharacter(userKey: string, name: string): boolean { + if (!_chars[userKey]) return false; + const before = _chars[userKey].characters.length; + _chars[userKey].characters = _chars[userKey].characters.filter( (c) => c.name.toLowerCase() !== name.toLowerCase() ); - if (_chars[usermapKey].characters.length === before) return false; + if (_chars[userKey].characters.length === before) return false; // If we removed the active one, set the first remaining as active - if (!_chars[usermapKey].characters.some((c) => c.active) && _chars[usermapKey].characters.length > 0) { - _chars[usermapKey].characters[0].active = true; + if (!_chars[userKey].characters.some((c) => c.active) && _chars[userKey].characters.length > 0) { + _chars[userKey].characters[0].active = true; } saveCharacters(); return true; } -export function setActiveCharacter(usermapKey: string, name: string): boolean { - const chars = _chars[usermapKey]?.characters; +export function setActiveCharacter(userKey: string, name: string): boolean { + const chars = _chars[userKey]?.characters; if (!chars) return false; const target = chars.find((c) => c.name.toLowerCase() === name.toLowerCase()); if (!target) return false; @@ -78,8 +78,8 @@ export function setActiveCharacter(usermapKey: string, name: string): boolean { return true; } -export function setCharacterNation(usermapKey: string, name: string, nation: Nation): boolean { - const char = getCharacterByName(usermapKey, name); +export function setCharacterNation(userKey: string, name: string, nation: Nation): boolean { + const char = getCharacterByName(userKey, name); if (!char) return false; char.nation = nation; saveCharacters(); @@ -87,11 +87,11 @@ export function setCharacterNation(usermapKey: string, name: string, nation: Nat } export function setCharacterStats( - usermapKey: string, + userKey: string, name: string, stats: { atk?: number; def?: number; heal?: number } ): boolean { - const char = getCharacterByName(usermapKey, name); + const char = getCharacterByName(userKey, name); if (!char) return false; if (!char.stats) char.stats = {}; Object.assign(char.stats, stats); @@ -100,12 +100,12 @@ export function setCharacterStats( } // ─── Account data ───────────────────────────────────────────────────────────── -export function getAccountData(usermapKey: string): AccountData { - return _accounts[usermapKey] ?? {}; +export function getAccountData(userKey: string): AccountData { + return _accounts[userKey] ?? {}; } -export function setAccountData(usermapKey: string, data: Partial): void { - if (!_accounts[usermapKey]) _accounts[usermapKey] = {}; - Object.assign(_accounts[usermapKey], data); +export function setAccountData(userKey: string, data: Partial): void { + if (!_accounts[userKey]) _accounts[userKey] = {}; + Object.assign(_accounts[userKey], data); saveAccounts(); } diff --git a/src/systems/config.ts b/src/systems/config.ts index cb7bfb0..39c1d05 100644 --- a/src/systems/config.ts +++ b/src/systems/config.ts @@ -36,6 +36,7 @@ function getDefaults(): Required { showNationTotalsInHeader: false, showNoInNationField: false, borrowRequestExpiryMs: 0, // 0 = never expire + conflictReclaimBehavior: "revert", }; } diff --git a/src/systems/conflict.ts b/src/systems/conflict.ts new file mode 100644 index 0000000..97f513b --- /dev/null +++ b/src/systems/conflict.ts @@ -0,0 +1,311 @@ +import { + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + EmbedBuilder, + ButtonInteraction, + TextChannel, +} from "discord.js"; +import { cfg } from "@systems/config"; +import { getCharacters, setActiveCharacter } from "@systems/characters"; +import { clearSessionBorrowForUser, clearPersistentPreference, getEffectiveCharacter } from "@systems/borrow"; +import { getImpersonation } from "@systems/impersonate"; +import { polls, updatePollMessage } from "@systems/poll"; +import { resolveMessage, nowFormatted } from "@systems/messages"; +import { getClassEmoji } from "@systems/emojis"; +import { format } from "@systems/format"; +import { Character } from "@types"; + +// ─── Config ─────────────────────────────────────────────────────────────────── +const RECLAIM_STYLE = ButtonStyle.Secondary; +const SWITCH_STYLE = ButtonStyle.Secondary; +const AUTO_VOTE_ON_SWITCH = process.env.AUTO_VOTE_ON_CONFLICT_SWITCH !== "false"; +const RECLAIM_NOTIFY_BORROWER = process.env.RECLAIM_NOTIFY_BORROWER !== "false"; + +// ─── State ──────────────────────────────────────────────────────────────────── +const pendingConflicts = new Map(); + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function applyCharToButton(btn: ButtonBuilder, char: Character): ButtonBuilder { + const emojiStr = getClassEmoji(char.class); + const emoji = format.emoji(emojiStr); + btn.setLabel(`${char.level} ${char.name}`); + if (emoji) btn.setEmoji(emoji as any); + return btn; +} + +function buildConflictEmbed(borrowerKey: string, char: Character): EmbedBuilder { + return new EmbedBuilder() + .setTitle("⚠️ Character Conflict") + .setDescription( + `**${format.char(char)}** is currently borrowed by **${borrowerKey}** for tonight's TG.\n\nYou can reclaim your character or switch to another one.` + ) + .setColor(0xe8a317); +} + +function buildConflictButtons( + ownerKey: string, + borrowerKey: string, + borrowedCharName: string, + ownerId: string, + allChars: Character[], + page: number +): ActionRowBuilder[] { + const PAGE_SIZE = 4; + const otherChars = allChars.filter((c) => c.name !== borrowedCharName); + const pageChars = otherChars.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + const hasMore = otherChars.length > (page + 1) * PAGE_SIZE; + const hasPrev = page > 0; + const rows: ActionRowBuilder[] = []; + + // Row 1: Reclaim button + const reclaimId = `conflict_reclaim:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}`; + pendingConflicts.set(reclaimId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page }); + const borrowed = allChars.find((c) => c.name === borrowedCharName); + const reclaimBtn = new ButtonBuilder().setCustomId(reclaimId).setStyle(RECLAIM_STYLE); + if (borrowed) { + reclaimBtn.setLabel(`Reclaim ${borrowed.level} ${borrowed.name}`); + const emojiStr = getClassEmoji(borrowed.class); + const emoji = format.emoji(emojiStr); + if (emoji) reclaimBtn.setEmoji(emoji as any); + } else { + reclaimBtn.setLabel(`Reclaim ${borrowedCharName}`); + } + rows.push(new ActionRowBuilder().addComponents(reclaimBtn)); + + // Row 2: Switch buttons + const charButtons = pageChars.map((char) => { + const id = `conflict_switch:${ownerKey}:${borrowerKey}:${char.name}:${ownerId}`; + pendingConflicts.set(id, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page }); + return applyCharToButton(new ButtonBuilder().setCustomId(id).setStyle(SWITCH_STYLE), char); + }); + if (charButtons.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...charButtons)); + } + + // Row 3: Pagination + const navButtons: ButtonBuilder[] = []; + if (hasPrev) { + const prevId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page - 1}`; + pendingConflicts.set(prevId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page - 1 }); + navButtons.push(new ButtonBuilder().setCustomId(prevId).setLabel("← Prev").setStyle(ButtonStyle.Primary)); + } + if (hasMore) { + const nextId = `conflict_page:${ownerKey}:${borrowerKey}:${borrowedCharName}:${ownerId}:${page + 1}`; + pendingConflicts.set(nextId, { ownerKey, borrowerKey, charName: borrowedCharName, ownerId, page: page + 1 }); + navButtons.push(new ButtonBuilder().setCustomId(nextId).setLabel("Next →").setStyle(ButtonStyle.Primary)); + } + if (navButtons.length > 0) { + rows.push(new ActionRowBuilder().addComponents(...navButtons)); + } + + return rows; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── +export async function showConflictEmbed( + interaction: ButtonInteraction, + ownerKey: string, + borrowerKey: string, + borrowedChar: Character, + allOwnerChars: Character[] +): Promise { + const embed = buildConflictEmbed(borrowerKey, borrowedChar); + const buttons = buildConflictButtons(ownerKey, borrowerKey, borrowedChar.name, interaction.user.id, allOwnerChars, 0); + await interaction.followUp({ embeds: [embed], components: buttons, ephemeral: true }); +} + +export async function handleConflictButton(interaction: ButtonInteraction): Promise { + const { customId } = interaction; + + // ── Pagination ────────────────────────────────────────────────────────────── + if (customId.startsWith("conflict_page:")) { + const parts = customId.split(":"); + const ownerKey = parts[1]; + const borrowerKey = parts[2]; + const charName = parts[3]; + const ownerId = parts[4]; + const page = parseInt(parts[5]); + + const allChars = getCharacters(ownerKey); + const borrowed = allChars.find((c) => c.name === charName); + if (!borrowed) return void interaction.reply({ content: "❌ Character not found.", ephemeral: true }); + + const embed = buildConflictEmbed(borrowerKey, borrowed); + const buttons = buildConflictButtons(ownerKey, borrowerKey, charName, ownerId, allChars, page); + await interaction.update({ embeds: [embed], components: buttons }); + return; + } + + // ── Switch to another char ────────────────────────────────────────────────── + if (customId.startsWith("conflict_switch:")) { + const parts = customId.split(":"); + const ownerKey = parts[1]; + const borrowerKey = parts[2]; + const newCharName = parts[3]; + const ownerId = parts[4]; + + setActiveCharacter(ownerKey, newCharName); + clearSessionBorrowForUser(ownerKey); + + const impersonating = getImpersonation(ownerId); + const voteId = impersonating ? `impersonated:${impersonating}` : ownerId; + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + if (state && AUTO_VOTE_ON_SWITCH) { + const guild = interaction.guild!; + const member = await guild.members.fetch(ownerId); + const { char } = getEffectiveCharacter(ownerKey); + const now = nowFormatted(); + const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null); + + state.yes.set(voteId, { + userKey: ownerKey, + displayName: member.nickname ?? member.user.globalName ?? member.user.username, + characterName: char?.name, + characterClass: char?.class, + characterLevel: char?.level, + characterNation: char?.nation, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + } + + const newChar = getCharacters(ownerKey).find((c) => c.name === newCharName); + const charDisplay = newChar ? format.char(newChar) : newCharName; + + await interaction.update({ + embeds: [new EmbedBuilder() + .setTitle("🔄 Switched") + .setDescription(`${charDisplay}${AUTO_VOTE_ON_SWITCH ? " — voted Yes." : ""}`) + .setColor(0x57f287)], + components: [], + }); + return; + } + + // ── Reclaim ───────────────────────────────────────────────────────────────── + if (customId.startsWith("conflict_reclaim:")) { + const parts = customId.split(":"); + const ownerKey = parts[1]; + const borrowerKey = parts[2]; + const charName = parts[3]; + const ownerId = parts[4]; + + const reclaimBehavior = (cfg as any)("conflictReclaimBehavior") ?? "revert"; + const slot = [...polls.keys()][0]; + const state = slot !== undefined ? polls.get(slot) : null; + + let borrowerDiscordId: string | undefined; + let borrowerVoteType: "yes" | "no" = "yes"; // default to yes + + if (state) { + for (const [id, entry] of [...state.yes.entries(), ...state.no.entries()]) { + const isYes = state.yes.has(id); + if (entry.userKey === borrowerKey) { + borrowerVoteType = isYes ? "yes" : "no"; + // Capture borrower's Discord ID for notification + borrowerDiscordId = (entry as any).discordId; + + if (reclaimBehavior === "remove") { + state.yes.delete(id); + state.no.delete(id); + } else { + // Clear borrow so getEffectiveCharacter returns own char + clearSessionBorrowForUser(borrowerKey); + clearPersistentPreference(borrowerKey); + const { char: ownChar } = getEffectiveCharacter(borrowerKey); + if (ownChar) { + entry.characterName = ownChar.name; + entry.characterClass = ownChar.class; + entry.characterLevel = ownChar.level; + entry.characterNation = ownChar.nation; + entry.borrowedFrom = undefined; + } else { + state.yes.delete(id); + state.no.delete(id); + } + } + break; + } + } + + // Owner joins with their reclaimed char + const guild = interaction.guild!; + const member = await guild.members.fetch(ownerId); + const impersonating = getImpersonation(ownerId); + const voteId = impersonating ? `impersonated:${impersonating}` : ownerId; + + setActiveCharacter(ownerKey, charName); + clearSessionBorrowForUser(ownerKey); + const { char } = getEffectiveCharacter(ownerKey); + const now = nowFormatted(); + const publicMsg = resolveMessage("public", "yes", 1, ownerKey, member.nickname ?? null, member.user.globalName ?? null); + + state.yes.set(voteId, { + userKey: ownerKey, + displayName: member.nickname ?? member.user.globalName ?? member.user.username, + characterName: char?.name, + characterClass: char?.class, + characterLevel: char?.level, + characterNation: char?.nation, + votedAt: now, + publicMessage: publicMsg ?? undefined, + }); + + const channel = await interaction.client.channels.fetch(cfg("pollChannelId")) as TextChannel; + await updatePollMessage(channel, slot!); + + // Notify borrower if enabled and we have their Discord ID + if (RECLAIM_NOTIFY_BORROWER && borrowerDiscordId) { + try { + const borrowerMember = await guild.members.fetch(borrowerDiscordId); + const borrowerChars = getCharacters(borrowerKey); + + if (borrowerChars.length > 0) { + const btns = borrowerChars.slice(0, 5).map((c) => + applyCharToButton( + new ButtonBuilder() + .setCustomId(`switch_after_reclaim:${borrowerKey}:${c.name}:${borrowerVoteType}`) + .setStyle(ButtonStyle.Secondary), + c + ) + ); + await borrowerMember.send({ + content: `⚠️ **${format.char({ class: char?.class ?? "FB" as any, level: char?.level ?? 0, name: charName })}** was reclaimed by **${ownerKey}**. Pick another character:`, + components: [new ActionRowBuilder().addComponents(...btns)], + }); + } else { + await borrowerMember.send({ + content: `⚠️ **${charName}** was reclaimed by **${ownerKey}**. You've been removed from the poll.`, + }); + } + } catch { + // DM may be disabled — silently ignore + } + } + } + + const borrowed = getCharacters(ownerKey).find((c) => c.name === charName); + const charDisplay = borrowed ? format.char(borrowed) : charName; + + await interaction.update({ + embeds: [new EmbedBuilder() + .setTitle("↩️ Reclaimed") + .setDescription(`${charDisplay} reclaimed from **${borrowerKey}**.`) + .setColor(0x57f287)], + components: [], + }); + return; + } +} \ No newline at end of file diff --git a/src/systems/format.ts b/src/systems/format.ts new file mode 100644 index 0000000..899e0b0 --- /dev/null +++ b/src/systems/format.ts @@ -0,0 +1,65 @@ +import { ClassKey, Nation } from "@src/types"; +import { getClassEmoji, getNationEmoji, getEmoji } from "@systems/emojis"; + +// ─── Individual formatters ──────────────────────────────────────────────────── + +export interface CharDisplayOptions { + emoji?: boolean; // show class emoji (default: true) + level?: boolean; // show level (default: true) +} + +/** + * Format a character for display in embeds and messages. + * Output: <:class:> 79 «Flash» + */ +function char( + c: { class: ClassKey; level: number; name: string }, + options?: CharDisplayOptions +): string { + const showEmoji = options?.emoji ?? true; + const showLevel = options?.level ?? true; + const classStr = showEmoji ? (getClassEmoji(c.class) || c.class) : c.class; + const levelStr = showLevel ? `${c.level} ` : ""; + return `${classStr} ${levelStr}${c.name}`.trim(); +} + +/** + * Format a nation name with its emoji. + * Output: <:capella:> Capella + */ +function nation(n: Nation): string { + const emoji = getNationEmoji(n); + return emoji ? `${emoji} ${n}` : n; +} + +/** + * Format a score line. + * Output: <:score:> 3000 <:kd:> 32/18 + */ +function score(pts: number, k?: number, d?: number): string { + const scoreEmoji = getEmoji("score") || "📊"; + const kdEmoji = getEmoji("kd") || "⚔️"; + const kdStr = k !== undefined && d !== undefined ? ` ${kdEmoji} ${k}/${d}` : ""; + return `${scoreEmoji} ${pts}${kdStr}`; +} + +/** + * Parse a Discord custom emoji string to an object for ButtonBuilder.setEmoji() + * Input: "<:fb:1511020923510194428>" + * Output: { name: "fb", id: "1511020923510194428" } + */ +function emoji(emojiStr: string): { name: string; id: string } | string | null { + if (!emojiStr) return null; + const match = emojiStr.match(/^<:(\w+):(\d+)>$/); + if (match) return { name: match[1], id: match[2] }; + return emojiStr; +} + +// ─── Namespace export ───────────────────────────────────────────────────────── + +export const format = { + char, + nation, + score, + emoji, +}; \ No newline at end of file diff --git a/src/systems/history.ts b/src/systems/history.ts index af92803..9ca89e8 100644 --- a/src/systems/history.ts +++ b/src/systems/history.ts @@ -42,7 +42,7 @@ export function upsertScore(score: TGScore): void { // Overwrite existing score for this player+slot result.scores = result.scores.filter( - (s) => !(s.usermapKey === score.usermapKey && s.slot === score.slot && s.date === score.date) + (s) => !(s.userKey === score.userKey && s.slot === score.slot && s.date === score.date) ); result.scores.push(score); saveResult(result); diff --git a/src/systems/impersonate.ts b/src/systems/impersonate.ts new file mode 100644 index 0000000..71e4005 --- /dev/null +++ b/src/systems/impersonate.ts @@ -0,0 +1,44 @@ +import fs from "fs"; +import path from "path"; + +const IMPERSONATE_RESET_ON_POLL = process.env.IMPERSONATE_RESET_ON_POLL !== "false"; +const IMPERSONATE_INDICATOR = process.env.IMPERSONATE_INDICATOR !== "false"; + +// realDiscordId → userKey being impersonated +const impersonations = new Map(); + +export function setImpersonation(realDiscordId: string, userKey: string): void { + impersonations.set(realDiscordId, userKey); +} + +export function clearImpersonation(realDiscordId: string): void { + impersonations.delete(realDiscordId); +} + +export function getImpersonation(realDiscordId: string): string | null { + return impersonations.get(realDiscordId) ?? null; +} + +export function clearAllImpersonations(): void { + if (IMPERSONATE_RESET_ON_POLL) impersonations.clear(); +} + +export function shouldShowIndicator(): boolean { + return IMPERSONATE_INDICATOR; +} + +// Returns all registered userKeys from usermap.json +export function getRegisteredUsers(): { userKey: string; aliases: string[] }[] { + try { + const usermap = JSON.parse( + fs.readFileSync(path.join(__dirname, "../../data/usermap.json"), "utf8") + ); + return Object.entries(usermap).map(([, entry]: [string, any]) => { + const fileKey = typeof entry === "string" ? entry : entry.file; + const aliases = typeof entry === "object" ? (entry.aliases ?? []) : []; + return { userKey: fileKey, aliases }; + }); + } catch { + return []; + } +} \ No newline at end of file diff --git a/src/systems/nations.ts b/src/systems/nations.ts index 06d471a..5278789 100644 --- a/src/systems/nations.ts +++ b/src/systems/nations.ts @@ -3,10 +3,10 @@ import { Nation } from "../types"; import { getActiveCharacter } from "./characters"; // Resolve a user's nation — character nation takes priority over Discord role -export function resolveNation(member: GuildMember, usermapKey: string | null): Nation | null { +export function resolveNation(member: GuildMember, userKey: string | null): Nation | null { // 1. Active character's nation - if (usermapKey) { - const char = getActiveCharacter(usermapKey); + if (userKey) { + const char = getActiveCharacter(userKey); if (char) return char.nation; } diff --git a/src/systems/poll.ts b/src/systems/poll.ts index c15c5d5..239f629 100644 --- a/src/systems/poll.ts +++ b/src/systems/poll.ts @@ -55,7 +55,7 @@ export function resetPollOverrides(): void { function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { const format = cfg("charDisplayFormat"); const nation = entry.characterNation; - const wRankEntry = entry.usermapKey ? getEntry(entry.usermapKey, nation ?? "Capella") : null; + const wRankEntry = entry.characterName ? getEntry(entry.characterName, nation ?? "Capella") : null; let wrank = ""; if (wRankEntry) { @@ -94,9 +94,9 @@ function formatCharRow(entry: VoteEntry, showNationEmoji = false): string { .trim(); // Bringer title — independent of W.Rank so override always shows - if (nation && entry.usermapKey) { + if (nation && entry.userKey) { const bringer = getBringer(nation); - if (bringer && bringer === entry.usermapKey) { + if (bringer && bringer === entry.characterName) { const emoji = nation === "Capella" ? (getEmoji("luminous_bringer") || "🔆") : (getEmoji("storm_bringer") || "⚡"); @@ -225,8 +225,11 @@ export async function updatePollMessage(channel: TextChannel, slot: number, over export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { resetPollOverrides(); - const { clearSessionBorrows } = require("./borrow"); + const { clearSessionBorrows } = require("@systems/borrow"); + const { clearAllImpersonations } = require("@systems/impersonate"); + clearSessionBorrows(); + clearAllImpersonations(); const state: PollState = { messageId: null, slot: slot.tgHour, @@ -242,7 +245,7 @@ export async function postPoll(channel: TextChannel, slot: TGSlot): Promise { const serverNickname = member.nickname ?? null; @@ -250,17 +253,18 @@ export function createVoteEntry( const displayName = serverNickname ?? globalNickname ?? discordUsername; const { getEffectiveCharacter } = require("./borrow"); - const { char, borrowedFrom: bf } = usermapKey - ? getEffectiveCharacter(usermapKey) + const { char, borrowedFrom: bf } = userKey + ? getEffectiveCharacter(userKey) : { char: null, borrowedFrom: null }; + console.log(`[createVoteEntry] userKey=${userKey} char=${char?.name} borrowedFrom=${bf}`); return { - usermapKey: usermapKey ?? (undefined as any), + userKey: userKey ?? (undefined as any), displayName, characterName: char?.name, characterClass: char?.class, characterLevel: char?.level, - characterNation: char?.nation ?? (resolveNation(member, usermapKey) ?? undefined), + characterNation: char?.nation ?? (resolveNation(member, userKey) ?? undefined), borrowedFrom: bf ?? undefined, }; } \ No newline at end of file diff --git a/src/systems/scores.ts b/src/systems/scores.ts index 87cf1a1..0be3927 100644 --- a/src/systems/scores.ts +++ b/src/systems/scores.ts @@ -53,11 +53,14 @@ export function detectSlot(): number | null { } export interface ScoreSubmission { - usermapKey: string; + userKey: string; // owner's key (score goes here) + playedBy?: string; // borrower's key if different from owner characterName: string; cls: ClassKey; nation: Nation; pts: number; + k?: number; + d?: number; slot: number; date?: string; atk?: number; @@ -71,11 +74,14 @@ export function submitScore(sub: ScoreSubmission): void { const historyKey = `${date}-${String(sub.slot).padStart(2, "0")}`; const score: TGScore = { - usermapKey: sub.usermapKey, + userKey: sub.userKey, + playedBy: sub.playedBy, characterName: sub.characterName, class: sub.cls, nation: sub.nation, pts: sub.pts, + k: sub.k, + d: sub.d, atk: sub.atk, def: sub.def, heal: sub.heal, @@ -86,5 +92,5 @@ export function submitScore(sub: ScoreSubmission): void { }; upsertScore(score); - recordScore(sub.usermapKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); + recordScore(sub.userKey, sub.characterName, sub.cls, sub.nation, sub.pts, historyKey); } diff --git a/src/systems/users.ts b/src/systems/users.ts index 9338e87..fb0b2c5 100644 --- a/src/systems/users.ts +++ b/src/systems/users.ts @@ -1,4 +1,5 @@ import { GuildMember } from "discord.js"; +import { getImpersonation } from "./impersonate"; import { ResolvedUser } from "../types"; import { getUsermapEntry } from "./messages"; import { getActiveCharacter } from "./characters"; @@ -10,15 +11,21 @@ export async function resolveUser(member: GuildMember): Promise { const globalNickname = member.user.globalName ?? null; const displayName = serverNickname ?? globalNickname ?? discordUsername; - const entry = getUsermapEntry(discordUsername); - const usermapKey = entry?.file ?? null; - const aliases = entry?.aliases ?? []; - const activeChar = usermapKey ? getActiveCharacter(usermapKey) : null; + // Check for active impersonation + const impersonatedKey = getImpersonation(member.user.id); + + const entry = impersonatedKey ? { file: impersonatedKey, aliases: [] } : getUsermapEntry(discordUsername); + const userKey = entry?.file ?? null; + const aliases = entry?.aliases ?? []; + const activeChar = userKey ? getActiveCharacter(userKey) : null; + // lookupUsername is used for message system lookups — use impersonated key if impersonating + const lookupUsername = impersonatedKey ?? discordUsername; return { userId: member.user.id, discordUsername, - usermapKey, + lookupUsername, + userKey, displayName, serverNickname, globalNickname, @@ -28,13 +35,13 @@ export async function resolveUser(member: GuildMember): Promise { } // Resolve a user by their usermap key (for officer commands using arg) -export function resolveByUsermapKey(key: string): { usermapKey: string; activeCharacter: ReturnType } { +export function resolveByUsermapKey(key: string): { userKey: string; activeCharacter: ReturnType } { return { - usermapKey: key, + userKey: key, activeCharacter: getActiveCharacter(key), }; } export function hasOfficerRole(member: GuildMember, officerRoles: string[]): boolean { return member.roles.cache.some((r) => officerRoles.includes(r.name)); -} +} \ No newline at end of file diff --git a/src/systems/wrank.ts b/src/systems/wrank.ts index 26861ce..63f6a0a 100644 --- a/src/systems/wrank.ts +++ b/src/systems/wrank.ts @@ -45,7 +45,7 @@ export function getWeek(weekKey: string): WRankWeek | null { // Add or update a score submission for a player export function recordScore( - usermapKey: string, + userKey: string, characterName: string, cls: ClassKey, nation: Nation, @@ -56,11 +56,11 @@ export function recordScore( const week = ensureWeek(weekKey); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; - const existing = list.find((e) => e.usermapKey === usermapKey); + const existing = list.find((e) => e.characterName === characterName); if (existing) { // Check if this slot was already counted - const alreadyCounted = week.scoreIndex[usermapKey]?.includes(historyKey); + const alreadyCounted = week.scoreIndex[userKey]?.includes(historyKey); if (!alreadyCounted) { existing.weeklyPoints += pts; existing.tgCount += 1; @@ -75,7 +75,7 @@ export function recordScore( existing.nation = nation; } else { list.push({ - usermapKey, + userKey, characterName, class: cls, nation, @@ -87,9 +87,10 @@ export function recordScore( } // Update score index - if (!week.scoreIndex[usermapKey]) week.scoreIndex[usermapKey] = []; - if (!week.scoreIndex[usermapKey].includes(historyKey)) { - week.scoreIndex[usermapKey].push(historyKey); + const indexKey = characterName; + if (!week.scoreIndex[indexKey]) week.scoreIndex[indexKey] = []; + if (!week.scoreIndex[indexKey].includes(historyKey)) { + week.scoreIndex[indexKey].push(historyKey); } recomputeRanks(week, nation); @@ -101,7 +102,7 @@ function recomputeRanks(week: WRankWeek, nation: Nation): void { const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; const sorted = [...list].sort((a, b) => b.weeklyPoints - a.weeklyPoints); sorted.forEach((entry, i) => { - const live = list.find((e) => e.usermapKey === entry.usermapKey)!; + const live = list.find((e) => e.characterName === entry.characterName)!; live.previousRank = live.currentRank || undefined; live.currentRank = i + 1; }); @@ -117,14 +118,14 @@ function updateBringer(week: WRankWeek): void { const qualified = week.entries[nation] .filter((e) => e.tgCount >= goal) .sort((a, b) => a.currentRank - b.currentRank); - week.bringer[nation] = qualified[0]?.usermapKey ?? null; + week.bringer[nation] = qualified[0]?.characterName ?? null; } } -export function setBringerOverride(nation: Nation, usermapKey: string): void { +export function setBringerOverride(nation: Nation, charName: string): void { const week = ensureWeek(getWeekKey()); - if (nation === "Capella") week.bringer.capellaOverride = usermapKey; - else week.bringer.procyonOverride = usermapKey; + if (nation === "Capella") week.bringer.capellaOverride = charName; + else week.bringer.procyonOverride = charName; saveWRank(); } @@ -142,10 +143,10 @@ export function getBringer(nation: Nation): string | null { return week.bringer.procyonOverride ?? week.bringer.procyon; } -export function getEntry(usermapKey: string, nation: Nation): WRankEntry | null { +export function getEntry(characterName: string, nation: Nation): WRankEntry | null { const week = getCurrentWeek(); const list = week.entries[nation.toLowerCase() as "capella" | "procyon"]; - return list.find((e) => e.usermapKey === usermapKey) ?? null; + return list.find((e) => e.characterName === characterName) ?? null; } // Called every Monday 00:00 by cron @@ -153,4 +154,4 @@ export function resetWeek(): void { // Week is already archived in _data by weekKey — just ensure next week exists ensureWeek(getWeekKey(new Date())); saveWRank(); -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index fcd59bf..9f5b2e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,7 +47,7 @@ export interface Character { } export interface CharacterMap { - [usermapKey: string]: { + [userKey: string]: { characters: Character[]; }; } @@ -68,7 +68,7 @@ export interface AccountData { } export interface AccountMap { - [usermapKey: string]: AccountData; + [userKey: string]: AccountData; } // ─── Usermap ───────────────────────────────────────────────────────────────── @@ -94,7 +94,7 @@ export interface TGSlot { // ─── Poll ──────────────────────────────────────────────────────────────────── export interface VoteEntry { - usermapKey: string; + userKey: string; displayName: string; // server nickname → global nickname → username characterName?: string; // active character name at time of vote characterClass?: ClassKey; // snapshotted @@ -107,6 +107,7 @@ export interface VoteEntry { publicMessageOverride?: string;// set by officer via /tg poll set-message ephemeralOverride?: string; // set by officer via /tg poll set-ephemeral borrowedFrom?: string // Borrowed character from who + discordId?: string; // real Discord ID of the voter (for notifications) } export interface PollState { @@ -123,11 +124,13 @@ export interface PollState { // ─── Scores ────────────────────────────────────────────────────────────────── export interface TGScore { - usermapKey: string; + userKey: string; characterName: string; class: ClassKey; nation: Nation; // snapshotted at submission time pts: number; + k?: number; + d?: number; atk?: number; def?: number; heal?: number; @@ -135,6 +138,7 @@ export interface TGScore { slot: number; // TG hour date: string; // YYYY-MM-DD submittedByOfficer: boolean; + playedBy?: string; // userKey of who actually played (if borrowed) } // ─── TG Result ─────────────────────────────────────────────────────────────── @@ -160,7 +164,7 @@ export interface TGResult { // ─── W.Rank ────────────────────────────────────────────────────────────────── export interface WRankEntry { - usermapKey: string; + userKey: string; characterName: string; // snapshotted class: ClassKey; // snapshotted nation: Nation; // snapshotted @@ -177,10 +181,10 @@ export interface WRankWeek { procyon: WRankEntry[]; }; scoreIndex: { - [usermapKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"] + [userKey: string]: string[]; // e.g. ["2026-05-31-20", "2026-06-01-22"] }; bringer: { - capella: string | null; // usermapKey of bringer, null if none qualified + capella: string | null; // userKey of bringer, null if none qualified procyon: string | null; capellaOverride?: string; // manually set by officer procyonOverride?: string; @@ -195,7 +199,7 @@ export interface WRankData { export interface BringerState { currentWeek: string; // "2026-W22" - capella: string | null; // usermapKey + capella: string | null; // userKey procyon: string | null; capellaOverride?: string; procyonOverride?: string; @@ -231,6 +235,7 @@ export interface BotConfig { showNationTotalsInHeader?: boolean; showNoInNationField?: boolean; borrowRequestExpiryMs?: number; // 0 = never expire (default) + conflictReclaimBehavior?: "revert" | "remove" } // ─── Messages ──────────────────────────────────────────────────────────────── @@ -266,7 +271,8 @@ export interface EmojiMap { export interface ResolvedUser { userId: string; discordUsername: string; // interaction.user.username - usermapKey: string | null; // resolved from usermap + userKey: string | null; // resolved from usermap + lookupUsername: string | null; displayName: string; // server nickname → global nickname → username serverNickname: string | null; globalNickname: string | null; diff --git a/src/utils.ts b/src/utils.ts index b5395e3..e45af70 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,18 +1,56 @@ import { ChatInputCommandInteraction, ButtonInteraction } from "discord.js"; -const EPHEMERAL_ENABLED = process.env.EPHEMERAL_ENABLED !== "false"; -const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); +// Poll vote confirmation messages (Yes/No button responses) +const POLL_EPHEMERAL_ENABLED = process.env.POLL_EPHEMERAL_ENABLED !== "false"; +// Command output messages (score, rank, status etc.) — always on by default +const COMMAND_EPHEMERAL_ENABLED = process.env.COMMAND_EPHEMERAL_ENABLED !== "false"; +const EPHEMERAL_DELETE_MS = parseInt(process.env.EPHEMERAL_DELETE_MS ?? "5000"); -export async function replyAndDelete( - interaction: ChatInputCommandInteraction | ButtonInteraction, +// For poll button responses +export async function pollReplyAndDelete( + interaction: ButtonInteraction, content: string | null ): Promise { - if (!content || !EPHEMERAL_ENABLED) { - if (interaction.isButton()) return void interaction.deferUpdate(); - return void interaction.deferReply({ ephemeral: true }).then(() => interaction.deleteReply()).catch(() => {}); - } - const reply = await interaction.reply({ content, ephemeral: true, fetchReply: true }); - if (EPHEMERAL_DELETE_MS > 0) { - setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + if (!content || !POLL_EPHEMERAL_ENABLED) return; + try { + const reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true }); + if (EPHEMERAL_DELETE_MS > 0) setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + } catch (err: any) { + console.error("[pollReplyAndDelete] error:", err.message); } } + +// For command responses — always sends regardless of POLL_EPHEMERAL_ENABLED +export async function replyAndDelete( + interaction: ChatInputCommandInteraction | ButtonInteraction, + content: string | null, + forceShow = false // set true for meaningful output that should always show +): Promise { + const enabled = forceShow || COMMAND_EPHEMERAL_ENABLED; + + if (!content || !enabled) { + if (interaction.isButton()) { + if (!interaction.deferred && !interaction.replied) await interaction.deferUpdate(); + return; + } + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + await interaction.deleteReply().catch(() => {}); + } + return; + } + + try { + let reply: any; + if (interaction.deferred || interaction.replied) { + reply = await interaction.followUp({ content, ephemeral: true, fetchReply: true }); + } else { + reply = await interaction.reply({ content, ephemeral: true, fetchReply: true }); + } + if (reply && EPHEMERAL_DELETE_MS > 0) { + setTimeout(() => reply.delete().catch(() => {}), EPHEMERAL_DELETE_MS); + } + } catch (err: any) { + console.error("[replyAndDelete] error:", err.message); + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 83a6139..4752ddc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,8 +9,25 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@src/*": ["src/*"], + "@data/*": ["data/*"], + "@messages/*": ["messages/*"], + "@tgHistory/*": ["data/tg-history/*"], + "@scripts/*": ["scripts/*"], + "@systems/*": ["src/systems/*"], + "@commands/*": ["src/commands/*"], + "@subcommands/*": ["src/subcommands/*"], + "@handlers/*": ["src/handlers/*"], + "@utils": ["src/utils"], + "@types": ["src/types"], + "@format": ["src/systems/format"], + "@emojis": ["src/systems/emojis"], + "@characters": ["src/systems/characters"], + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] -} +} \ No newline at end of file