diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..38bc8e0 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "none" +} diff --git a/bin/deletecmd.ts b/bin/deletecmd.ts index 5228192..9721991 100644 --- a/bin/deletecmd.ts +++ b/bin/deletecmd.ts @@ -1,6 +1,6 @@ require('dotenv').config(); -import { Routes, REST } from "discord.js"; -import logger from "../src/utilities/Logger"; +import { Routes, REST } from 'discord.js'; +import logger from '../src/utilities/Logger'; const cmdToDelete = '1478498588362277006'; const rest = new REST({ version: '10' }).setToken(process.env.BOT_TOKEN!); @@ -8,7 +8,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.BOT_TOKEN!); try { logger.info(`Attempting to delete the command /${cmdToDelete} ...`); rest.delete(Routes.applicationCommand(process.env.APP_ID!, cmdToDelete)); - logger.info('... Successfully deleted the command') + logger.info('... Successfully deleted the command'); } catch (err) { throw err; -} \ No newline at end of file +} diff --git a/bin/registercmds.ts b/bin/registercmds.ts index 007b08e..1ee01e1 100644 --- a/bin/registercmds.ts +++ b/bin/registercmds.ts @@ -1,8 +1,8 @@ -import { Routes, REST } from "discord.js"; -import { readdirSync } from "fs"; +import { Routes, REST } from 'discord.js'; +import { readdirSync } from 'fs'; import path from 'path'; -import SlashCommand from "../src/structures/SlashCommand"; -import logger from "../src/utilities/Logger"; +import SlashCommand from '../src/structures/SlashCommand'; +import logger from '../src/utilities/Logger'; require('dotenv').config(); const commandDirectory = './src/commands/'; @@ -15,29 +15,36 @@ for (const file of commandFiles) { const command = require(fullPath); const Module = new command.default(); if (!(Module instanceof SlashCommand)) continue; - const isGlobal = Module.isGlobalCommand(); - const commandData = Module.getData(); - if (isGlobal) { - globalCommandArray.push(commandData.toJSON()); + if (Module.isGlobalCommand) { + globalCommandArray.push(Module.data.toJSON()); } else { - commandArray.push(commandData.toJSON()); + commandArray.push(Module.data.toJSON()); } } const rest = new REST({ version: '10' }).setToken(process.env.BOT_TOKEN!); try { - logger.info(`Attempting to register ${commandArray.length} server commands...`); - rest.put(Routes.applicationGuildCommands(process.env.APP_ID!, process.env.GUILD_ID!), { body: commandArray }); + logger.info( + `Attempting to register ${commandArray.length} server commands...` + ); + rest.put( + Routes.applicationGuildCommands(process.env.APP_ID!, process.env.GUILD_ID!), + { body: commandArray } + ); logger.info('Successfully registered commands!'); } catch (err) { throw err; } try { - logger.info(`Attempting to register ${globalCommandArray.length} global commands...`); - rest.put(Routes.applicationCommands(process.env.APP_ID!), { body: globalCommandArray }); + logger.info( + `Attempting to register ${globalCommandArray.length} global commands...` + ); + rest.put(Routes.applicationCommands(process.env.APP_ID!), { + body: globalCommandArray + }); logger.info('Successfully registered commands!'); } catch (err) { throw err; -} \ No newline at end of file +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..539d6f8 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,193 @@ +import typescriptEslint from '@typescript-eslint/eslint-plugin' +import stylistic from '@stylistic/eslint-plugin' +import tsParser from '@typescript-eslint/parser' +import globals from 'globals' + +export default [ + { + files: ['src/**/*.ts'], + + plugins: { + '@typescript-eslint': typescriptEslint, + '@stylistic': stylistic + }, + + languageOptions: { + globals: { + ...globals.node + }, + + parser: tsParser, + ecmaVersion: 13, + sourceType: 'module', + + parserOptions: { + ecmaFeatures: { + impliedStrict: true + }, + + project: ['./tsconfig.json'] + } + }, + + rules: { + '@typescript-eslint/array-type': [ + 'error', + { + default: 'array', + readonly: 'array' + } + ], + + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + '@typescript-eslint/ban-tslint-comment': 'error', + '@typescript-eslint/class-literal-property-style': ['error', 'fields'], + '@stylistic/comma-dangle': ['error'], + '@typescript-eslint/consistent-indexed-object-style': ['error', 'record'], + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + fixStyle: 'inline-type-imports', + }, + ], + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/explicit-function-return-type': 'error', + '@stylistic/keyword-spacing': 'error', + '@stylistic/member-delimiter-style': 'error', + '@typescript-eslint/method-signature-style': ['error', 'property'], + '@typescript-eslint/no-array-constructor': 'error', + '@typescript-eslint/no-dupe-class-members': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-extra-non-null-assertion': 'error', + '@stylistic/no-extra-parens': 'error', + '@stylistic/no-extra-semi': 'error', + '@typescript-eslint/no-extraneous-class': 'error', + '@typescript-eslint/no-loss-of-precision': 'error', + + '@typescript-eslint/no-misused-promises': [ + 'error', + { + checksConditionals: true, + checksVoidReturn: false + } + ], + + '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', + '@typescript-eslint/no-this-alias': 'error', + '@typescript-eslint/only-throw-error': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-unnecessary-type-constraint': 'error', + '@typescript-eslint/no-useless-empty-export': 'error', + '@typescript-eslint/non-nullable-type-assertion-style': 'error', + '@stylistic/object-curly-spacing': ['error', 'always'], + '@typescript-eslint/prefer-for-of': 'error', + '@typescript-eslint/prefer-includes': 'error', + '@typescript-eslint/prefer-literal-enum-member': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + '@typescript-eslint/prefer-reduce-type-parameter': 'error', + '@typescript-eslint/prefer-return-this-type': 'error', + '@typescript-eslint/require-array-sort-compare': 'error', + '@typescript-eslint/restrict-plus-operands': 'error', + '@stylistic/semi': ['error', 'always'], + '@stylistic/semi-spacing': 'error', + '@stylistic/space-before-blocks': 'error', + '@stylistic/type-annotation-spacing': 'error', + + '@typescript-eslint/typedef': [ + 'error', + { + memberVariableDeclaration: true, + parameter: true, + propertyDeclaration: true, + variableDeclaration: false + } + ], + + '@typescript-eslint/unified-signatures': 'error', + 'block-scoped-var': 'error', + 'block-spacing': 'error', + camelcase: 'error', + 'comma-style': 'error', + 'default-case-last': 'error', + 'dot-notation': 'error', + 'generator-star-spacing': 'error', + 'getter-return': 'error', + 'implicit-arrow-linebreak': 'error', + indent: ['error', 2], + 'key-spacing': 'error', + 'new-parens': 'error', + 'no-alert': 'error', + 'no-class-assign': 'error', + 'no-const-assign': 'error', + 'no-constructor-return': 'error', + 'no-control-regex': 'error', + 'no-delete-var': 'error', + 'no-dupe-args': 'error', + 'no-dupe-else-if': 'error', + 'no-dupe-keys': 'error', + 'no-duplicate-case': 'error', + 'no-else-return': 'error', + 'no-empty-pattern': 'error', + 'no-eval': 'error', + 'no-ex-assign': 'error', + 'no-extend-native': 'error', + 'no-fallthrough': 'error', + 'no-func-assign': 'error', + 'no-global-assign': 'error', + 'no-import-assign': 'error', + 'no-inner-declarations': 'error', + 'no-invalid-regexp': 'error', + 'no-label-var': 'error', + 'no-lonely-if': 'error', + 'no-mixed-spaces-and-tabs': 'error', + 'no-new': 'error', + 'no-new-func': 'error', + 'no-new-object': 'error', + 'no-new-symbol': 'error', + 'no-new-wrappers': 'error', + 'no-nonoctal-decimal-escape': 'error', + 'no-obj-calls': 'error', + 'no-prototype-builtins': 'error', + + 'no-return-assign': 'error', + 'no-self-assign': 'error', + 'no-setter-return': 'error', + 'no-shadow-restricted-names': 'error', + 'no-sparse-arrays': 'error', + 'no-template-curly-in-string': 'error', + 'no-undef-init': 'error', + 'no-unneeded-ternary': 'error', + 'no-unreachable': 'error', + 'no-unsafe-optional-chaining': 'error', + 'no-unused-labels': 'error', + 'no-useless-call': 'error', + 'no-useless-catch': 'error', + 'no-useless-computed-key': 'error', + 'no-useless-concat': 'error', + 'no-useless-escape': 'error', + 'no-useless-rename': 'error', + 'no-useless-return': 'error', + 'no-var': 'error', + 'no-whitespace-before-property': 'error', + 'no-with': 'error', + 'object-shorthand': 'error', + 'operator-assignment': 'error', + 'prefer-arrow-callback': 'error', + 'prefer-const': 'error', + 'prefer-exponentiation-operator': 'error', + 'prefer-object-has-own': 'error', + 'prefer-promise-reject-errors': 'error', + 'prefer-rest-params': 'error', + 'prefer-spread': 'error', + 'prefer-template': 'error', + 'require-yield': 'error', + strict: 'error', + 'switch-colon-spacing': 'error', + 'template-curly-spacing': 'error', + 'use-isnan': 'error', + 'valid-typeof': 'error', + 'yield-star-spacing': 'error' + } + } +] diff --git a/package-lock.json b/package-lock.json index af40a17..c815ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "dfo-discord-bot", "version": "1.0.0", + "license": "Apache-2.0", "dependencies": { "@napi-rs/canvas": "^0.1.97", "discord-hybrid-sharding": "^3.0.1", @@ -16,9 +17,15 @@ "pino": "^10.2.1" }, "devDependencies": { + "@stylistic/eslint-plugin": "^5.10.0", "@types/mongoose": "^5.11.96", "@types/node": "^20.11.24", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^10.1.0", + "globals": "^17.4.0", "pino-pretty": "^13.1.3", + "prettier": "^3.8.1", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.9.3" @@ -167,6 +174,204 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -492,6 +697,40 @@ "npm": ">=7.0.0" } }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -520,6 +759,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mongoose": { "version": "5.11.96", "resolved": "https://registry.npmjs.org/@types/mongoose/-/mongoose-5.11.96.tgz", @@ -577,6 +837,278 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vladfrangu/async_event_emitter": { "version": "2.4.7", "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", @@ -600,6 +1132,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -613,6 +1155,23 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -749,6 +1308,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -759,6 +1333,31 @@ "node": "*" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", @@ -843,6 +1442,264 @@ "once": "^1.4.0" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-copy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz", @@ -850,10 +1707,24 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, "license": "MIT" }, "node_modules/fast-safe-stringify": { @@ -863,6 +1734,19 @@ "dev": true, "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -876,6 +1760,44 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -943,6 +1865,19 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -963,6 +1898,26 @@ "dev": true, "license": "MIT" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -1044,6 +1999,13 @@ "node": ">=0.12.0" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -1054,6 +2016,27 @@ "node": ">=10" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/kareem": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", @@ -1063,6 +2046,46 @@ "node": ">=18.0.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.23", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", @@ -1234,6 +2257,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1263,6 +2293,66 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -1273,6 +2363,16 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1355,6 +2455,32 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -1480,6 +2606,42 @@ ], "license": "BSD-3-Clause" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sift": { "version": "17.1.3", "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", @@ -1582,6 +2744,54 @@ "node": ">=20" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1617,6 +2827,19 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -1731,6 +2954,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1760,6 +2996,16 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -1789,6 +3035,32 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1836,6 +3108,19 @@ "engines": { "node": ">=6" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 9637ea3..170a732 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "main": "dist/index.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "lint": "eslint --fix", + "prettier": "prettier **/*.ts --write", "build": "tsc && node -e \"const fs=require('fs'); ['commands', 'events', 'components/buttons', 'components/menus', 'components/modals'].forEach(d => fs.mkdirSync('./dist/' + d, { recursive: true }));\"", "start": "node dist/index.js" }, @@ -18,9 +20,15 @@ "pino": "^10.2.1" }, "devDependencies": { + "@stylistic/eslint-plugin": "^5.10.0", "@types/mongoose": "^5.11.96", "@types/node": "^20.11.24", + "@typescript-eslint/eslint-plugin": "^8.57.2", + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^10.1.0", + "globals": "^17.4.0", "pino-pretty": "^13.1.3", + "prettier": "^3.8.1", "ts-node": "^10.9.2", "ts-node-dev": "^2.0.0", "typescript": "^5.9.3" diff --git a/src/bot.ts b/src/bot.ts index fd9184f..8d23e70 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -13,22 +13,24 @@ limitations under the License. */ -import logger, { flushAndClose } from "./utilities/Logger"; -import { Client, GatewayIntentBits } from "discord.js"; +import logger, { flushAndClose } from './utilities/Logger'; +import { Client, GatewayIntentBits } from 'discord.js'; import 'dotenv/config'; -import EventHandler from "./handlers/EventHandler"; -import SlashCommandHandler from "./handlers/SlashCommandHandler"; -import ButtonHandler from "./handlers/ButtonHandler"; -import SelectMenuHandler from "./handlers/SelectMenuHandler"; -import ModalSubmitHandler from "./handlers/ModalSubmitHandler"; -import WorkerPool from "./utilities/WorkerPool"; -import PresenceManager from "./managers/PresenceManager"; +import * as SlashCommandHandler from './handlers/SlashCommandHandler'; +import * as ButtonHandler from './handlers/ButtonHandler'; +import * as SelectMenuHandler from './handlers/SelectMenuHandler'; +import * as ModalSubmitHandler from './handlers/ModalSubmitHandler'; +import * as WorkerPool from './utilities/WorkerPool'; +import * as PresenceManager from './managers/PresenceManager'; +import initializeEventHandler from './handlers/EventHandler'; -const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); +const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] +}); -(async () => { +(async (): Promise => { try { - new EventHandler(client); + initializeEventHandler(client); SlashCommandHandler.load(); ButtonHandler.load(); SelectMenuHandler.load(); @@ -41,7 +43,7 @@ const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBit } })(); -async function shutdown(signal: string) { +async function shutdown(signal: string): Promise { logger.info(`[System] Received ${signal}. Starting shutdown...`); try { diff --git a/src/commands/AttackCommand.ts b/src/commands/AttackCommand.ts index 0395a2b..9eeea3c 100644 --- a/src/commands/AttackCommand.ts +++ b/src/commands/AttackCommand.ts @@ -1,17 +1,26 @@ -import { ChatInputCommandInteraction, Client } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { ICombatJSON } from "../interfaces/ICombatJSON"; -import { apiFetch } from "../utilities/ApiClient"; -import { buildCombatResponse } from "../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { type ICombatJSON } from '../interfaces/ICombatJSON'; +import { apiFetch } from '../utilities/ApiClient'; +import { buildCombatResponse } from '../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; export default class AttackCommand extends SlashCommand { constructor() { - super('attack', 'Attack the enemy in your encounter', 'Gaming'); + super({ + name: 'attack', + description: 'Attack the enemy in your encounter', + category: 'Gaming', + cooldown: 1.8, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const res = await apiFetch(Routes.combat(), { @@ -24,7 +33,7 @@ export default class AttackCommand extends SlashCommand { return; } - const data = await res.json() as ICombatJSON; + const data = (await res.json()) as ICombatJSON; if (data.error) { await interaction.editReply({ content: formatError(data.error) }); @@ -34,7 +43,4 @@ export default class AttackCommand extends SlashCommand { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 1.8; } -} \ No newline at end of file +} diff --git a/src/commands/ChestsCommand.ts b/src/commands/ChestsCommand.ts index e1c1442..92386c0 100644 --- a/src/commands/ChestsCommand.ts +++ b/src/commands/ChestsCommand.ts @@ -1,20 +1,35 @@ import { - ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, - ChatInputCommandInteraction, Client, EmbedBuilder, -} from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; -import ImageService from "../utilities/ImageService"; -import type { IChestSlot } from "../interfaces/IGameJSON"; + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + type Client, + EmbedBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; +import * as ImageService from '../utilities/ImageService'; +import type { IChestSlot } from '../interfaces/IGameJSON'; +import { chunkArray } from '../utilities/helpers'; export default class ChestsCommand extends SlashCommand { constructor() { - super('chests', 'View and manage your chest vault', 'Gaming'); + super({ + name: 'chests', + description: 'View and manage your chest vault', + category: 'Gaming', + cooldown: 5, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const discordId = interaction.user.id; @@ -24,16 +39,22 @@ export default class ChestsCommand extends SlashCommand { const playerBody = await res.json(); if (!res.ok) { - await interaction.editReply({ content: formatError(playerBody.error ?? 'Failed to load player') }); + await interaction.editReply({ + content: formatError(playerBody.error ?? 'Failed to load player') + }); return; } // Fetch chest data via the chests endpoint (GET with discordId) - const chestRes = await apiFetch(`${Routes.chests()}?discordId=${discordId}`); + const chestRes = await apiFetch( + `${Routes.chests()}?discordId=${discordId}` + ); const chestBody = await chestRes.json(); if (!chestRes.ok) { - await interaction.editReply({ content: formatError(chestBody.error ?? 'Failed to load chests') }); + await interaction.editReply({ + content: formatError(chestBody.error ?? 'Failed to load chests') + }); return; } @@ -48,16 +69,22 @@ export default class ChestsCommand extends SlashCommand { maxSlots, divinePity, pityThreshold, - totalOpened, + totalOpened }); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'chests.png' }); - const embed = new EmbedBuilder().setColor(0xeab308).setImage('attachment://chests.png'); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'chests.png' + }); + const embed = new EmbedBuilder() + .setColor(0xeab308) + .setImage('attachment://chests.png'); const components: ActionRowBuilder[] = []; // Action buttons for each chest (max 2 rows of 4) - const actionable = chests.filter(c => c.status === 'ready' || c.status === 'locked'); + const actionable = chests.filter( + (c) => c.status === 'ready' || c.status === 'locked' + ); const chunks = chunkArray(actionable, 4); for (const chunk of chunks.slice(0, 2)) { @@ -97,25 +124,20 @@ export default class ChestsCommand extends SlashCommand { new ButtonBuilder() .setCustomId('chest_buy:Rare') .setLabel('๐Ÿ”ต Buy Rare') - .setStyle(ButtonStyle.Secondary), + .setStyle(ButtonStyle.Secondary) ); components.push(shopRow); } - await interaction.editReply({ embeds: [embed], files: [attachment], components }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } -} - -function chunkArray(arr: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - chunks.push(arr.slice(i, i + size)); - } - return chunks; } diff --git a/src/commands/CollectionCommand.ts b/src/commands/CollectionCommand.ts index 8ce3f10..910882a 100644 --- a/src/commands/CollectionCommand.ts +++ b/src/commands/CollectionCommand.ts @@ -1,37 +1,52 @@ -import { ChatInputCommandInteraction, Client, EmbedBuilder } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { ICollectionJSON } from "../interfaces/ICollectionJSON"; -import ItemManager from "../managers/ItemManager"; -import PaginatorBuilder from "../utilities/PaginatorBuilder"; -import Routes from "../utilities/Routes"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; +import { + type ChatInputCommandInteraction, + type Client, + EmbedBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { type ICollectionJSON } from '../interfaces/ICollectionJSON'; +import * as ItemManager from '../managers/ItemManager'; +import PaginatorBuilder from '../utilities/PaginatorBuilder'; +import * as Routes from '../utilities/Routes'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; export default class CollectionCommand extends SlashCommand { constructor() { - super('collection', 'View your or another player\'s item collection', 'General'); - - this.data.addUserOption((o) => - o.setName('user') - .setDescription('Select a user') - .setRequired(false) + super({ + name: 'collection', + description: "View your or another player's item collection", + category: 'General', + cooldown: 5, + isGlobalCommand: true + }); + + this.builder.addUserOption((o) => o.setName('user').setDescription('Select a user').setRequired(false) ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); - - const targetUser = interaction.options.getUser('user', false) ?? interaction.user; + + const targetUser = + interaction.options.getUser('user', false) ?? interaction.user; const res = await apiFetch(Routes.player(targetUser.id)); if (res.status === 404) { - await interaction.editReply({ content: `${targetUser.username} hasn't made any DFO player data!` }); + await interaction.editReply({ + content: `${targetUser.username} hasn't made any DFO player data!` + }); return; } if (!res.ok) { - await interaction.editReply({ content: formatError('Failed to load player data') }); + await interaction.editReply({ + content: formatError('Failed to load player data') + }); return; } @@ -41,15 +56,20 @@ export default class CollectionCommand extends SlashCommand { // Safely parse the "Map" from JSON into an array of [itemId, quantity] let collectionItems: [string, number][] = []; if (collection?.items) { - if (typeof collection.items === 'object' && !Array.isArray(collection.items)) { - collectionItems = Object.entries(collection.items); - } else if (collection.items instanceof Map) { - collectionItems = Array.from(collection.items.entries()); - } + if ( + typeof collection.items === 'object' && + !Array.isArray(collection.items) + ) { + collectionItems = Object.entries(collection.items); + } else if (collection.items instanceof Map) { + collectionItems = Array.from(collection.items.entries()); + } } if (collectionItems.length === 0) { - await interaction.editReply({ content: `๐Ÿ“– **${targetUser.username}** hasn't discovered any items yet.` }); + await interaction.editReply({ + content: `๐Ÿ“– **${targetUser.username}** hasn't discovered any items yet.` + }); return; } @@ -62,13 +82,13 @@ export default class CollectionCommand extends SlashCommand { for (let i = 0; i < collectionItems.length; i += ITEMS_PER_PAGE) { const chunk = collectionItems.slice(i, i + ITEMS_PER_PAGE); - + let descriptionText = statsHeader; for (const [itemIdString, quantity] of chunk) { const itemId = parseInt(itemIdString, 10); const itemData = ItemManager.get(itemId); - + if (itemData) { descriptionText += `โœจ **${itemData.name}** (x${quantity})\n`; descriptionText += `โ”” *${itemData.rarity} ${itemData.type}*\n\n`; @@ -93,12 +113,4 @@ export default class CollectionCommand extends SlashCommand { await paginator.start(interaction); } - - public isGlobalCommand(): boolean { - return true; - } - - public cooldown(): number { - return 5; - } } diff --git a/src/commands/ExploreCommand.ts b/src/commands/ExploreCommand.ts index bb4ea9f..24e3c2e 100644 --- a/src/commands/ExploreCommand.ts +++ b/src/commands/ExploreCommand.ts @@ -1,33 +1,46 @@ -import { ChatInputCommandInteraction, Client } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { IStepJSON } from "../interfaces/IStepJSON"; -import { apiFetch } from "../utilities/ApiClient"; -import { buildCombatResponse } from "../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { type IStepJSON } from '../interfaces/IStepJSON'; +import { apiFetch } from '../utilities/ApiClient'; +import { buildCombatResponse } from '../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; export default class ExploreCommand extends SlashCommand { constructor() { - super('explore', 'Explore the world and find items or enemy encounters!', 'Gaming'); + super({ + name: 'explore', + description: 'Explore the world and find items or enemy encounters!', + category: 'Gaming', + cooldown: 7, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const res = await apiFetch(Routes.explore(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id }), + body: JSON.stringify({ discordId: interaction.user.id }) }); - const data = await res.json() as IStepJSON; + const data = (await res.json()) as IStepJSON; if (res.status === 429) { - await interaction.editReply({ content: formatCooldown('step', data.cooldownRemaining) }); + await interaction.editReply({ + content: formatCooldown('step', data.cooldownRemaining) + }); return; } if (res.status === 404) { - await interaction.editReply({ content: formatError('', 'PLAYER_NOT_FOUND') }); + await interaction.editReply({ + content: formatError('', 'PLAYER_NOT_FOUND') + }); return; } @@ -39,7 +52,4 @@ export default class ExploreCommand extends SlashCommand { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 7; } -} \ No newline at end of file +} diff --git a/src/commands/FleeCommand.ts b/src/commands/FleeCommand.ts index 8951687..4d28603 100644 --- a/src/commands/FleeCommand.ts +++ b/src/commands/FleeCommand.ts @@ -1,17 +1,26 @@ -import { ChatInputCommandInteraction, Client } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { ICombatJSON } from "../interfaces/ICombatJSON"; -import { apiFetch } from "../utilities/ApiClient"; -import { buildCombatResponse } from "../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { type ICombatJSON } from '../interfaces/ICombatJSON'; +import { apiFetch } from '../utilities/ApiClient'; +import { buildCombatResponse } from '../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; export default class FleeCommand extends SlashCommand { constructor() { - super('flee', 'Flee the enemy encounter you\'re in.', 'Gaming'); + super({ + name: 'flee', + description: "Flee the enemy encounter you're in.", + category: 'Gaming', + cooldown: 2, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const res = await apiFetch(Routes.combat(), { @@ -24,7 +33,7 @@ export default class FleeCommand extends SlashCommand { return; } - const data = await res.json() as ICombatJSON; + const data = (await res.json()) as ICombatJSON; if (data.error) { await interaction.editReply({ content: formatError(data.error) }); @@ -34,7 +43,4 @@ export default class FleeCommand extends SlashCommand { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 2; } -} \ No newline at end of file +} diff --git a/src/commands/GuideCommand.ts b/src/commands/GuideCommand.ts index 683716a..68a6a79 100644 --- a/src/commands/GuideCommand.ts +++ b/src/commands/GuideCommand.ts @@ -1,12 +1,22 @@ -import { ChatInputCommandInteraction, Client, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; +import { + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; -const SECTIONS: Record = { +const SECTIONS: Record< + string, + { title: string; emoji: string; content: string } +> = { basics: { title: 'Getting Started', emoji: '๐Ÿ“–', content: [ - '**Welcome to Dragon\'s Fall Online!**', + "**Welcome to Dragon's Fall Online!**", '', '`/register` โ€” Create your character', '`/explore` โ€” Take a step in your current zone. You may find gold, items, or enemies!', @@ -18,8 +28,8 @@ const SECTIONS: Record HP regenerates passively โ€” 10% of max HP every 5 minutes.', '> Use `/rest` to heal instantly at an inn (costs gold).', '> Consumable items can also restore HP.', - '> HP is fully restored on level up.', - ].join('\n'), + '> HP is fully restored on level up.' + ].join('\n') }, combat: { title: 'Combat & Enemies', @@ -35,30 +45,30 @@ const SECTIONS: Record Crit Chance (cap: 75%) โ€ข Life Steal (cap: 25%) โ€ข Dodge (cap: 75%)', - '> Gold Find (cap: 100%) โ€ข XP Bonus (cap: 100%) โ€ข Thorns (flat damage)', - ].join('\n'), + '> Gold Find (cap: 100%) โ€ข XP Bonus (cap: 100%) โ€ข Thorns (flat damage)' + ].join('\n') }, workshop: { title: 'Workshop โ€” Enhance, Reforge, Dismantle', emoji: '๐Ÿ”จ', content: [ - '**Access the workshop from any item\'s detail view.**', + "**Access the workshop from any item's detail view.**", '', - 'โฌ†๏ธ **Enhance** โ€” Increase an item\'s stats. Costs gold + embers.', + "โฌ†๏ธ **Enhance** โ€” Increase an item's stats. Costs gold + embers.", '> +1 to +5: Guaranteed success', '> +6 to +10: Decreasing success chance (80% โ†’ 20%)', '> Failed attempts consume resources. High-level failures may destroy the item.', '> Enhanced items become unique variants โ€” they split from stacks.', '', - '๐Ÿ”„ **Reforge** โ€” Reroll an item\'s stats and/or affixes. Costs gold.', + "๐Ÿ”„ **Reforge** โ€” Reroll an item's stats and/or affixes. Costs gold.", '> Stats: Reroll ATK/DEF/HP values', '> Affixes: Reroll special effects', '> Full: Reroll everything (costs more)', '', '๐Ÿ”ฅ **Dismantle** โ€” Destroy items to earn **Embers**.', '> Enhanced items return 50% of the embers invested in them.', - '> Embers are used for enhancement and other upgrades.', - ].join('\n'), + '> Embers are used for enhancement and other upgrades.' + ].join('\n') }, economy: { title: 'Economy & Gold Sinks', @@ -80,8 +90,8 @@ const SECTIONS: Record โš ๏ธ Permanent action! Items are removed from inventory.', '> Hit milestones for gold, XP, embers, and chests.', - '> Modified items cannot be collected.', - ].join('\n'), + '> Modified items cannot be collected.' + ].join('\n') }, tasks: { title: 'Tasks & Chests', @@ -96,8 +106,8 @@ const SECTIONS: Record Earn chests from exploring, milestones, or buy from the shop.', '> Some chests unlock instantly; others take time.', '> Open chests for items, gold, and embers.', - '> **Divine Pity**: After opening many chests without a Divine drop, one is guaranteed.', - ].join('\n'), + '> **Divine Pity**: After opening many chests without a Divine drop, one is guaranteed.' + ].join('\n') }, zones: { title: 'Zones & Travel', @@ -112,30 +122,46 @@ const SECTIONS: Record **Toll Cost** โ€” Gold charged per step (Zones 7+)', '', 'Higher zones have tougher enemies but better rewards and rarer drops.', - 'Zone XP multipliers are capped at 3.0ร— to prevent runaway leveling.', - ].join('\n'), - }, + 'Zone XP multipliers are capped at 3.0ร— to prevent runaway leveling.' + ].join('\n') + } }; -const SECTION_ORDER = ['basics', 'combat', 'workshop', 'economy', 'tasks', 'zones']; +const SECTION_ORDER = [ + 'basics', + 'combat', + 'workshop', + 'economy', + 'tasks', + 'zones' +]; export default class GuideCommand extends SlashCommand { constructor() { - super('guide', 'View the DFO game guide', 'General'); - this.data.addStringOption((o) => - o.setName('section') - .setDescription('Jump to a specific section') - .setRequired(false) - .addChoices( - ...SECTION_ORDER.map(key => ({ - name: `${SECTIONS[key].emoji} ${SECTIONS[key].title}`, - value: key, - })) - ) + super({ + name: 'guide', + description: 'View the DFO game guide', + category: 'General', + cooldown: 3, + isGlobalCommand: true + }); + this.builder.addStringOption((o) => o + .setName('section') + .setDescription('Jump to a specific section') + .setRequired(false) + .addChoices( + ...SECTION_ORDER.map((key) => ({ + name: `${SECTIONS[key].emoji} ${SECTIONS[key].title}`, + value: key + })) + ) ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { const section = interaction.options.getString('section') ?? 'basics'; const data = SECTIONS[section] || SECTIONS.basics; @@ -143,7 +169,9 @@ export default class GuideCommand extends SlashCommand { .setColor(0x10b981) .setTitle(`${data.emoji} ${data.title}`) .setDescription(data.content) - .setFooter({ text: `DFO Guide โ€ข Use /guide
to jump to a topic` }); + .setFooter({ + text: `DFO Guide โ€ข Use /guide
to jump to a topic` + }); // Section navigation buttons const currentIdx = SECTION_ORDER.indexOf(section); @@ -173,10 +201,7 @@ export default class GuideCommand extends SlashCommand { await interaction.reply({ embeds: [embed], - components: navRow.components.length > 0 ? [navRow] : [], + components: navRow.components.length > 0 ? [navRow] : [] }); } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/commands/HelpCommand.ts b/src/commands/HelpCommand.ts index e2cd422..7fa7bf3 100644 --- a/src/commands/HelpCommand.ts +++ b/src/commands/HelpCommand.ts @@ -1,42 +1,54 @@ -import { ChatInputCommandInteraction, Client, EmbedBuilder, Colors } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import SlashCommandHandler from "../handlers/SlashCommandHandler"; -import PaginatorBuilder from "../utilities/PaginatorBuilder"; +import { + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + Colors +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import * as SlashCommandHandler from '../handlers/SlashCommandHandler'; +import PaginatorBuilder from '../utilities/PaginatorBuilder'; const CATEGORY_ICONS: Record = { - 'General': '๐Ÿ“‹', - 'Gaming': 'โš”๏ธ', - 'Moderator': '๐Ÿ›ก๏ธ', - 'Developer': '๐Ÿ”ง', + General: '๐Ÿ“‹', + Gaming: 'โš”๏ธ', + Moderator: '๐Ÿ›ก๏ธ', + Developer: '๐Ÿ”ง' }; const CATEGORY_COLORS: Record = { - 'General': 0x3b82f6, - 'Gaming': 0xef4444, - 'Moderator': 0xf59e0b, - 'Developer': 0x6b7280, + General: 0x3b82f6, + Gaming: 0xef4444, + Moderator: 0xf59e0b, + Developer: 0x6b7280 }; export default class HelpCommand extends SlashCommand { constructor() { - super('help', 'View all available commands and how to get started', 'General'); + super({ + name: 'help', + description: 'View all available commands and how to get started', + category: 'General', + cooldown: 3, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); - const commands = SlashCommandHandler.getCache(); - // Group commands by category const categories = new Map(); - for (const command of commands.values()) { - const cat = command.getCategory(); + for (const command of SlashCommandHandler.cache.values()) { // Hide developer commands from regular users - if (cat === 'Developer') continue; + if (command.category === 'Developer') continue; - if (!categories.has(cat)) categories.set(cat, []); - categories.get(cat)!.push(command); + if (!categories.has(command.category)) + categories.set(command.category, []); + categories.get(command.category)!.push(command); } const pages: EmbedBuilder[] = []; @@ -44,16 +56,16 @@ export default class HelpCommand extends SlashCommand { // Page 1: Overview / Getting Started const overviewEmbed = new EmbedBuilder() .setColor(0x10b981) - .setTitle('โš”๏ธ Dragon\'s Fall Online') + .setTitle("โš”๏ธ Dragon's Fall Online") .setDescription( 'A lightweight text-based MMORPG. Collect thousands of unique items, explore endless scenarios, and watch numbers go up.\n\n' + - '**Getting Started:**\n' + - '> 1. Run `/register` to create your character\n' + - '> 2. Use `/explore` to adventure and find loot\n' + - '> 3. Check `/inventory` to manage your gear\n' + - '> 4. View `/profile` to see your stats\n\n' + - '**Links:**\n' + - '> ๐ŸŒ [Play on Web](https://capi.gg/dfo) โ€ข ๐Ÿ—ณ๏ธ [Vote on top.gg](https://top.gg) โ€ข ๐Ÿ’ฌ [Discord Server](https://discord.gg/dfo)' + '**Getting Started:**\n' + + '> 1. Run `/register` to create your character\n' + + '> 2. Use `/explore` to adventure and find loot\n' + + '> 3. Check `/inventory` to manage your gear\n' + + '> 4. View `/profile` to see your stats\n\n' + + '**Links:**\n' + + '> ๐ŸŒ [Play on Web](https://capi.gg/dfo) โ€ข ๐Ÿ—ณ๏ธ [Vote on top.gg](https://top.gg) โ€ข ๐Ÿ’ฌ [Discord Server](https://discord.gg/dfo)' ) .setThumbnail(client.user?.displayAvatarURL() ?? ''); @@ -66,7 +78,7 @@ export default class HelpCommand extends SlashCommand { let description = ''; for (const cmd of cmds) { - description += `**\`/${cmd.getName()}\`** โ€” ${cmd.getDescription()}\n`; + description += `**\`/${cmd.name}\`** โ€” ${cmd.description}\n`; } const embed = new EmbedBuilder() @@ -84,12 +96,4 @@ export default class HelpCommand extends SlashCommand { await paginator.start(interaction); } - - public isGlobalCommand(): boolean { - return true; - } - - public cooldown(): number { - return 3; - } -} \ No newline at end of file +} diff --git a/src/commands/InventoryCommand.ts b/src/commands/InventoryCommand.ts index 7692b9d..4dbfbc2 100644 --- a/src/commands/InventoryCommand.ts +++ b/src/commands/InventoryCommand.ts @@ -1,33 +1,59 @@ import { - ChatInputCommandInteraction, Client, EmbedBuilder, AttachmentBuilder, - ButtonBuilder, ButtonStyle, ActionRowBuilder, StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { IInventoryItem } from "../interfaces/IInventoryJSON"; -import { IPlayerJSON } from "../interfaces/IPlayerJSON"; -import PaginatorBuilder from "../utilities/PaginatorBuilder"; -import Routes from "../utilities/Routes"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import ItemManager from "../managers/ItemManager"; -import ImageService from "../utilities/ImageService"; + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { type IInventoryItem } from '../interfaces/IInventoryJSON'; +import { type IPlayerJSON } from '../interfaces/IPlayerJSON'; +import PaginatorBuilder from '../utilities/PaginatorBuilder'; +import * as Routes from '../utilities/Routes'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import * as ItemManager from '../managers/ItemManager'; +import * as ImageService from '../utilities/ImageService'; export default class InventoryCommand extends SlashCommand { constructor() { - super('inventory', 'View your inventory and manage items', 'General'); + super({ + name: 'inventory', + description: 'View your inventory and manage items', + category: 'General', + cooldown: 5, + isGlobalCommand: true + }); // No options โ€” the select menu handles item selection } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const res = await apiFetch(Routes.inventory(interaction.user.id)); - const { success, data, error }: { success: boolean, data: any, error?: string } = await res.json(); - - if (res.status === 400 || res.status === 401 || res.status === 404 || res.status === 500) { - await interaction.editReply({ content: formatError(error ?? 'Unknown error') }); + const { + success, + data, + error + }: { success: boolean; data: any; error?: string } = await res.json(); + + if ( + res.status === 400 || + res.status === 401 || + res.status === 404 || + res.status === 500 + ) { + await interaction.editReply({ + content: formatError(error ?? 'Unknown error') + }); return; } @@ -35,7 +61,9 @@ export default class InventoryCommand extends SlashCommand { const player = data.player as IPlayerJSON; if (!inventory || inventory.length === 0) { - await interaction.editReply({ content: `๐ŸŽ’ **${interaction.user.username}**'s inventory is completely empty.` }); + await interaction.editReply({ + content: `๐ŸŽ’ **${interaction.user.username}**'s inventory is completely empty.` + }); return; } @@ -82,11 +110,15 @@ export default class InventoryCommand extends SlashCommand { .setMaxValues(1) .addOptions(selectOptions.slice(0, 25)); - pageRows.push(new ActionRowBuilder().setComponents(selectMenu)); + pageRows.push( + new ActionRowBuilder().setComponents( + selectMenu + ) + ); } // === BULK ACTION BUTTONS === - const eligibleCount = chunk.filter(inv => { + const eligibleCount = chunk.filter((inv) => { if (inv.isLocked) return false; const def = ItemManager.get(inv.itemId); if (!def || def.type === 'Consumable') return false; @@ -107,7 +139,7 @@ export default class InventoryCommand extends SlashCommand { new ButtonBuilder() .setCustomId(`bulk_dismantle:${i}`) .setLabel(`๐Ÿ”ฅ Bulk Dismantle (${eligibleCount})`) - .setStyle(ButtonStyle.Danger), + .setStyle(ButtonStyle.Danger) ) ); } @@ -124,7 +156,4 @@ export default class InventoryCommand extends SlashCommand { await paginator.start(interaction); } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/commands/LeaderboardCommand.ts b/src/commands/LeaderboardCommand.ts index de99709..2af7cf7 100644 --- a/src/commands/LeaderboardCommand.ts +++ b/src/commands/LeaderboardCommand.ts @@ -1,85 +1,106 @@ -import { AttachmentBuilder, ChatInputCommandInteraction, Client, EmbedBuilder } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { apiFetch } from "../utilities/ApiClient"; -import Routes from "../utilities/Routes"; -import { formatError } from "../utilities/ErrorMessages"; -import { type LeaderboardEntry, type LeaderboardConfig } from "../utilities/LeaderboardImageBuilder"; -import ImageService from "../utilities/ImageService"; +import { + AttachmentBuilder, + type ChatInputCommandInteraction, + type Client, + EmbedBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { apiFetch } from '../utilities/ApiClient'; +import * as Routes from '../utilities/Routes'; +import { formatError } from '../utilities/ErrorMessages'; +import { + type LeaderboardEntry, + type LeaderboardConfig +} from '../utilities/LeaderboardImageBuilder'; +import * as ImageService from '../utilities/ImageService'; const STAT_OPTIONS = [ { name: 'Level', value: 'level' }, { name: 'Gold', value: 'coins' }, { name: 'Enemies Defeated', value: 'enemiesDefeated' }, - { name: 'Days Explored', value: 'daysPassed' }, + { name: 'Days Explored', value: 'daysPassed' } ]; const STAT_DISPLAY: Record = { - 'level': { + level: { title: 'Leaderboard โ€” Level', stat: 'Level', emoji: 'โญ', accentColor: '#eab308', - accentColorDim: '#eab30825', + accentColorDim: '#eab30825' }, - 'coins': { + coins: { title: 'Leaderboard โ€” Gold', stat: 'Gold', emoji: '๐Ÿช™', accentColor: '#f59e0b', - accentColorDim: '#f59e0b25', + accentColorDim: '#f59e0b25' }, - 'enemiesDefeated': { + enemiesDefeated: { title: 'Leaderboard โ€” Enemies Defeated', stat: 'Enemies Defeated', emoji: '๐Ÿ’€', accentColor: '#ef4444', - accentColorDim: '#ef444425', + accentColorDim: '#ef444425' }, - 'daysPassed': { + daysPassed: { title: 'Leaderboard โ€” Days Explored', stat: 'Days Explored', emoji: '๐Ÿ“…', accentColor: '#3b82f6', - accentColorDim: '#3b82f625', - }, + accentColorDim: '#3b82f625' + } }; export default class LeaderboardCommand extends SlashCommand { constructor() { - super('leaderboard', 'View the top players', 'General'); - - this.data.addStringOption((o) => - o.setName('stat') - .setDescription('Which stat to rank by') - .setChoices(STAT_OPTIONS) - .setRequired(false) + super({ + name: 'leaderboard', + description: 'View the top players', + category: 'General', + cooldown: 10, + isGlobalCommand: true + }); + + this.builder.addStringOption((o) => o + .setName('stat') + .setDescription('Which stat to rank by') + .setChoices(STAT_OPTIONS) + .setRequired(false) ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const stat = interaction.options.getString('stat', false) ?? 'level'; - const config = STAT_DISPLAY[stat] ?? STAT_DISPLAY['level']; + const config = STAT_DISPLAY[stat] ?? STAT_DISPLAY.level; try { const res = await apiFetch(Routes.leaderboard(stat)); if (!res.ok) { const body = await res.json().catch(() => ({})); - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load leaderboard') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load leaderboard') + }); return; } const { data }: { data: any[] } = await res.json(); if (!data || data.length === 0) { - await interaction.editReply({ content: '๐Ÿ“Š **No players found yet.** Be the first to `/register`!' }); + await interaction.editReply({ + content: '๐Ÿ“Š **No players found yet.** Be the first to `/register`!' + }); return; } // Map API data to the image builder's expected shape - const entries: LeaderboardEntry[] = data.map(player => { + const entries: LeaderboardEntry[] = data.map((player) => { let value: number; if (stat === 'enemiesDefeated' || stat === 'daysPassed') { value = player.statistics?.[stat] ?? 0; @@ -90,12 +111,14 @@ export default class LeaderboardCommand extends SlashCommand { return { username: player.username, value, - level: player.level ?? 1, + level: player.level ?? 1 }; }); const imageBuffer = await ImageService.leaderboard(entries, config); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'leaderboard.png' }); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'leaderboard.png' + }); const embed = new EmbedBuilder() .setColor(parseInt(config.accentColor.replace('#', ''), 16)) @@ -103,15 +126,9 @@ export default class LeaderboardCommand extends SlashCommand { await interaction.editReply({ embeds: [embed], files: [attachment] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isGlobalCommand(): boolean { - return true; - } - - public cooldown(): number { - return 10; - } -} \ No newline at end of file +} diff --git a/src/commands/LookupCommand.ts b/src/commands/LookupCommand.ts index a693cb1..9599f80 100644 --- a/src/commands/LookupCommand.ts +++ b/src/commands/LookupCommand.ts @@ -1,33 +1,60 @@ -import { AutocompleteInteraction, ChatInputCommandInteraction, Client, Colors, EmbedBuilder, MessageFlags } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import ItemManager from "../managers/ItemManager"; -import PaginatorBuilder from "../utilities/PaginatorBuilder"; -import ItemLookupContainer from "../structures/containers/ItemLookupContainer"; -import { apiFetch } from "../utilities/ApiClient"; -import Routes from "../utilities/Routes"; -import { IScenarioJSON } from "../interfaces/IScenarioJSON"; -import ScenarioLookupContainer from "../structures/containers/ScenarioLookupContainer"; -import { INPCJSON } from "../interfaces/INPCJSON"; -import NPCLookupContainer from "../structures/containers/NPCLookupContainer"; +import { + type AutocompleteInteraction, + type ChatInputCommandInteraction, + type Client, + Colors, + EmbedBuilder, + MessageFlags +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import * as ItemManager from '../managers/ItemManager'; +import PaginatorBuilder from '../utilities/PaginatorBuilder'; +import ItemLookupContainer from '../structures/containers/ItemLookupContainer'; +import { apiFetch } from '../utilities/ApiClient'; +import * as Routes from '../utilities/Routes'; +import { type IScenarioJSON } from '../interfaces/IScenarioJSON'; +import ScenarioLookupContainer from '../structures/containers/ScenarioLookupContainer'; +import { type INPCJSON } from '../interfaces/INPCJSON'; +import NPCLookupContainer from '../structures/containers/NPCLookupContainer'; const typeOptions = [ { name: 'Item', value: 'item' }, { name: 'Scenario', value: 'scenario' }, - { name: 'NPC', value: 'npc' }, + { name: 'NPC', value: 'npc' } ]; export default class LookupCommand extends SlashCommand { constructor() { - super('lookup', 'Lookup specific objects in the game', 'Moderator'); - - this.data.addStringOption((o) => o.setName('type').setDescription('Select a type').setChoices(typeOptions).setRequired(true)); - this.data.addIntegerOption((o) => o.setName('id').setDescription('Enter an id to lookup. Use -1 for all').setMinValue(-1).setRequired(true).setAutocomplete(true)); + super({ + name: 'lookup', + description: 'Lookup specific objects in the game', + category: 'Moderator', + cooldown: 3, + isGlobalCommand: false + }); + + this.builder.addStringOption((o) => o + .setName('type') + .setDescription('Select a type') + .setChoices(typeOptions) + .setRequired(true) + ); + this.builder.addIntegerOption((o) => o + .setName('id') + .setDescription('Enter an id to lookup. Use -1 for all') + .setMinValue(-1) + .setRequired(true) + .setAutocomplete(true) + ); } /** * Autocomplete handler (#9) โ€” suggests items by name when type is 'item' */ - public async autocomplete(interaction: AutocompleteInteraction, client: Client): Promise { + public async autocomplete( + interaction: AutocompleteInteraction, + client: Client + ): Promise { const type = interaction.options.getString('type'); const focused = interaction.options.getFocused(true); @@ -41,9 +68,12 @@ export default class LookupCommand extends SlashCommand { // Filter by name match, return up to 25 suggestions (Discord limit) const matches = items - .filter(item => item.name.toLowerCase().includes(query) || String(item.itemId).startsWith(query)) + .filter( + (item) => item.name.toLowerCase().includes(query) || + String(item.itemId).startsWith(query) + ) .slice(0, 25) - .map(item => ({ + .map((item) => ({ name: `[${item.itemId}] ${item.name} (${item.rarity} Lvl ${item.level})`, value: item.itemId })); @@ -51,115 +81,161 @@ export default class LookupCommand extends SlashCommand { await interaction.respond(matches); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const choice = interaction.options.getString('type', true); const id = interaction.options.getInteger('id', true); switch (choice) { - case 'item': - if (id === -1) { - const items = Array.from(ItemManager.cache.values()); - const ITEMS_PER_PAGE = 10; - const pages: EmbedBuilder[] = []; - - for (let i = 0; i < items.length; i += ITEMS_PER_PAGE) { - const chunk = items.slice(i, i + ITEMS_PER_PAGE); - let descriptionText = ''; - - for (const item of chunk) { - descriptionText += `**LVL ${item.level} ${item.name} ID:** \`${item.itemId}\`\n`; - descriptionText += `โ”” ${item.rarity} ${item.type} | **HP:** \`${item.stats.hp.toLocaleString()}\`; **ATK:** \`${item.stats.atk.toLocaleString()}\`; **DEF:** \`${item.stats.def.toLocaleString()}\`\n`; - if (item.affixes) { - let textToAdd = ''; - for (const affix of item.affixes) { - textToAdd += affix.type === 'THORNS' - ? ` **${affix.type}:** \`${affix.value.toLocaleString()}\` |` - : ` **${affix.type}:** \`${affix.value.toLocaleString()}%\` |`; - } - if (textToAdd !== '') descriptionText += `โ€Ž โ€Ž โ€Ž โ€Ž โ”” ${textToAdd}\n\n`; - else descriptionText += '\n'; + case 'item': + if (id === -1) { + const items = Array.from(ItemManager.cache.values()); + const ITEMS_PER_PAGE = 10; + const pages: EmbedBuilder[] = []; + + for (let i = 0; i < items.length; i += ITEMS_PER_PAGE) { + const chunk = items.slice(i, i + ITEMS_PER_PAGE); + let descriptionText = ''; + + for (const item of chunk) { + descriptionText += `**LVL ${item.level} ${item.name} ID:** \`${item.itemId}\`\n`; + descriptionText += `โ”” ${item.rarity} ${item.type} | **HP:** \`${item.stats.hp.toLocaleString()}\`; **ATK:** \`${item.stats.atk.toLocaleString()}\`; **DEF:** \`${item.stats.def.toLocaleString()}\`\n`; + if (item.affixes) { + let textToAdd = ''; + for (const affix of item.affixes) { + textToAdd += + affix.type === 'THORNS' + ? ` **${affix.type}:** \`${affix.value.toLocaleString()}\` |` + : ` **${affix.type}:** \`${affix.value.toLocaleString()}%\` |`; } + if (textToAdd !== '') + descriptionText += `โ€Ž โ€Ž โ€Ž โ€Ž โ”” ${textToAdd}\n\n`; + else descriptionText += '\n'; } - - pages.push(new EmbedBuilder().setColor(Colors.Green).setTitle('Item Manager').setDescription(descriptionText)); } - await new PaginatorBuilder().setPages(pages).setTargetUser(interaction.user.id).setIdleTimeout(60_000).start(interaction); + pages.push( + new EmbedBuilder() + .setColor(Colors.Green) + .setTitle('Item Manager') + .setDescription(descriptionText) + ); + } + + await new PaginatorBuilder() + .setPages(pages) + .setTargetUser(interaction.user.id) + .setIdleTimeout(60_000) + .start(interaction); + } else { + const item = ItemManager.get(id); + if (!item) { + await interaction.editReply({ + content: 'No item with that id exists!' + }); return; - } else { - const item = ItemManager.get(id); - if (!item) { - await interaction.editReply({ content: 'No item with that id exists!' }); - return; - } - await interaction.editReply({ components: [new ItemLookupContainer(item).build()], flags: MessageFlags.IsComponentsV2 }); } - break; - - case 'scenario': - if (id === -1) { - const res = await apiFetch(Routes.scenarios()); - if (!res.ok) throw new Error('API Error!'); - const { data }: { data: IScenarioJSON[] } = await res.json(); - - const ITEMS_PER_PAGE = 10; - const pages: EmbedBuilder[] = []; - - for (let i = 0; i < data.length; i += ITEMS_PER_PAGE) { - const chunk = data.slice(i, i + ITEMS_PER_PAGE); - let descriptionText = ''; - for (const scenario of chunk) { - descriptionText += `๐Ÿ“œ \`\`\`${scenario.description.length > 128 ? scenario.description.substring(0, 125) + '...' : scenario.description}\`\`\`\n`; - descriptionText += `โ”” **ID:** \`${scenario.id}\` | **Author:** \`${scenario.createdBy}\`\n\n`; - } - pages.push(new EmbedBuilder().setTitle('Scenario Manager').setDescription(descriptionText)); + await interaction.editReply({ + components: [new ItemLookupContainer(item).build()], + flags: MessageFlags.IsComponentsV2 + }); + } + break; + + case 'scenario': + if (id === -1) { + const res = await apiFetch(Routes.scenarios()); + if (!res.ok) throw new Error('API Error!'); + const { data }: { data: IScenarioJSON[] } = await res.json(); + + const ITEMS_PER_PAGE = 10; + const pages: EmbedBuilder[] = []; + + for (let i = 0; i < data.length; i += ITEMS_PER_PAGE) { + const chunk = data.slice(i, i + ITEMS_PER_PAGE); + let descriptionText = ''; + for (const scenario of chunk) { + descriptionText += `๐Ÿ“œ \`\`\`${scenario.description.length > 128 ? `${scenario.description.substring(0, 125)}...` : scenario.description}\`\`\`\n`; + descriptionText += `โ”” **ID:** \`${scenario.id}\` | **Author:** \`${scenario.createdBy}\`\n\n`; } + pages.push( + new EmbedBuilder() + .setTitle('Scenario Manager') + .setDescription(descriptionText) + ); + } - await new PaginatorBuilder().setPages(pages).setTargetUser(interaction.user.id).setIdleTimeout(60_000).start(interaction); + await new PaginatorBuilder() + .setPages(pages) + .setTargetUser(interaction.user.id) + .setIdleTimeout(60_000) + .start(interaction); + } else { + const res = await apiFetch(Routes.scenario(id)); + if (res.status === 404) { + await interaction.editReply({ + content: 'No scenario was found for this id!' + }); return; - } else { - const res = await apiFetch(Routes.scenario(id)); - if (res.status === 404) { await interaction.editReply({ content: 'No scenario was found for this id!' }); return; } - if (!res.ok) throw new Error('API Error!'); - const { data }: { data: IScenarioJSON } = await res.json(); - await interaction.editReply({ components: [new ScenarioLookupContainer(data).build()], flags: MessageFlags.IsComponentsV2 }); } - break; - - case 'npc': - if (id === -1) { - const res = await apiFetch(Routes.npcs()); - if (!res.ok) throw new Error('API Error!'); - const { data }: { data: INPCJSON[] } = await res.json(); - - const ITEMS_PER_PAGE = 10; - const pages: EmbedBuilder[] = []; - - for (let i = 0; i < data.length; i += ITEMS_PER_PAGE) { - const chunk = data.slice(i, i + ITEMS_PER_PAGE); - let descriptionText = ''; - for (const npc of chunk) { - descriptionText += `๐Ÿ’€ (ID: \`${npc.id}\`) **${npc.name}**\n`; - descriptionText += `โ”” ${npc.description.length > 128 ? npc.description.substring(0, 125) + '...' : npc.description}\n\n`; - } - pages.push(new EmbedBuilder().setTitle('NPC Manager').setDescription(descriptionText)); + if (!res.ok) throw new Error('API Error!'); + const { data }: { data: IScenarioJSON } = await res.json(); + await interaction.editReply({ + components: [new ScenarioLookupContainer(data).build()], + flags: MessageFlags.IsComponentsV2 + }); + } + break; + + case 'npc': + if (id === -1) { + const res = await apiFetch(Routes.npcs()); + if (!res.ok) throw new Error('API Error!'); + const { data }: { data: INPCJSON[] } = await res.json(); + + const ITEMS_PER_PAGE = 10; + const pages: EmbedBuilder[] = []; + + for (let i = 0; i < data.length; i += ITEMS_PER_PAGE) { + const chunk = data.slice(i, i + ITEMS_PER_PAGE); + let descriptionText = ''; + for (const npc of chunk) { + descriptionText += `๐Ÿ’€ (ID: \`${npc.id}\`) **${npc.name}**\n`; + descriptionText += `โ”” ${npc.description.length > 128 ? `${npc.description.substring(0, 125)}...` : npc.description}\n\n`; } + pages.push( + new EmbedBuilder() + .setTitle('NPC Manager') + .setDescription(descriptionText) + ); + } - await new PaginatorBuilder().setPages(pages).setTargetUser(interaction.user.id).setIdleTimeout(60_000).start(interaction); + await new PaginatorBuilder() + .setPages(pages) + .setTargetUser(interaction.user.id) + .setIdleTimeout(60_000) + .start(interaction); + } else { + const res = await apiFetch(Routes.npc(id)); + if (!res.ok) throw new Error('API Error!'); + const { success, data }: { success: boolean; data: INPCJSON } = + await res.json(); + if (!success) { + await interaction.editReply({ + content: 'No NPC was found for the provided ID!' + }); return; - } else { - const res = await apiFetch(Routes.npc(id)); - if (!res.ok) throw new Error('API Error!'); - const { success, data }: { success: boolean, data: INPCJSON } = await res.json(); - if (!success) { await interaction.editReply({ content: 'No NPC was found for the provided ID!' }); return; } - await interaction.editReply({ components: [new NPCLookupContainer(data).build()], flags: MessageFlags.IsComponentsV2 }); } - break; + await interaction.editReply({ + components: [new NPCLookupContainer(data).build()], + flags: MessageFlags.IsComponentsV2 + }); + } + break; } } - - public isGlobalCommand(): boolean { return false; } - public cooldown(): number { return 3; } -} \ No newline at end of file +} diff --git a/src/commands/MarketCommand.ts b/src/commands/MarketCommand.ts index 3b43512..ee4224a 100644 --- a/src/commands/MarketCommand.ts +++ b/src/commands/MarketCommand.ts @@ -1,23 +1,38 @@ import { - ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, - ChatInputCommandInteraction, Client, EmbedBuilder, MessageFlags, - StringSelectMenuBuilder, StringSelectMenuOptionBuilder, -} from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; -import { type MarketListing, type MarketPageConfig } from "../utilities/MarketImageBuilder"; -import ImageService from "../utilities/ImageService"; -import ItemManager from "../managers/ItemManager"; -import type { IInventoryItem } from "../interfaces/IInventoryJSON"; + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + MessageFlags, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; +import { + type MarketListing, + type MarketPageConfig +} from '../utilities/MarketImageBuilder'; +import * as ImageService from '../utilities/ImageService'; +import * as ItemManager from '../managers/ItemManager'; +import type { IInventoryItem } from '../interfaces/IInventoryJSON'; +import { chunkArray } from '../utilities/helpers'; const RARITY_CHOICES = [ { name: 'All', value: 'All' }, - { name: 'Common', value: 'Common' }, { name: 'Uncommon', value: 'Uncommon' }, - { name: 'Rare', value: 'Rare' }, { name: 'Elite', value: 'Elite' }, - { name: 'Epic', value: 'Epic' }, { name: 'Legendary', value: 'Legendary' }, - { name: 'Divine', value: 'Divine' }, { name: 'Exotic', value: 'Exotic' }, + { name: 'Common', value: 'Common' }, + { name: 'Uncommon', value: 'Uncommon' }, + { name: 'Rare', value: 'Rare' }, + { name: 'Elite', value: 'Elite' }, + { name: 'Epic', value: 'Epic' }, + { name: 'Legendary', value: 'Legendary' }, + { name: 'Divine', value: 'Divine' }, + { name: 'Exotic', value: 'Exotic' } ]; const SORT_CHOICES = [ @@ -27,52 +42,90 @@ const SORT_CHOICES = [ { name: 'Level: High โ†’ Low', value: 'level_desc' }, { name: 'Highest ATK', value: 'atk_desc' }, { name: 'Highest DEF', value: 'def_desc' }, - { name: 'Highest HP', value: 'hp_desc' }, + { name: 'Highest HP', value: 'hp_desc' } ]; const TYPE_CHOICES = [ { name: 'All', value: 'All' }, - { name: 'Weapon', value: 'Weapon' }, { name: 'Armor', value: 'Armor' }, - { name: 'Accessory', value: 'Accessory' }, { name: 'Consumable', value: 'Consumable' }, + { name: 'Weapon', value: 'Weapon' }, + { name: 'Armor', value: 'Armor' }, + { name: 'Accessory', value: 'Accessory' }, + { name: 'Consumable', value: 'Consumable' } ]; export const SELL_PAGE_SIZE = 25; export default class MarketCommand extends SlashCommand { constructor() { - super('market', 'Browse and trade on the Global Market', 'Gaming'); + super({ + name: 'market', + description: 'Browse and trade on the Global Market', + category: 'Gaming', + cooldown: 5, + isGlobalCommand: true + }); this.data - .addSubcommand((sub) => - sub.setName('browse') - .setDescription('Browse items for sale on the Global Market') - .addStringOption((o) => o.setName('search').setDescription('Search by item name').setRequired(false)) - .addStringOption((o) => o.setName('rarity').setDescription('Filter by rarity').setChoices(RARITY_CHOICES).setRequired(false)) - .addStringOption((o) => o.setName('type').setDescription('Filter by item type').setChoices(TYPE_CHOICES).setRequired(false)) - .addStringOption((o) => o.setName('sort').setDescription('Sort order').setChoices(SORT_CHOICES).setRequired(false)) + .addSubcommand((sub) => sub + .setName('browse') + .setDescription('Browse items for sale on the Global Market') + .addStringOption((o) => o + .setName('search') + .setDescription('Search by item name') + .setRequired(false) + ) + .addStringOption((o) => o + .setName('rarity') + .setDescription('Filter by rarity') + .setChoices(RARITY_CHOICES) + .setRequired(false) + ) + .addStringOption((o) => o + .setName('type') + .setDescription('Filter by item type') + .setChoices(TYPE_CHOICES) + .setRequired(false) + ) + .addStringOption((o) => o + .setName('sort') + .setDescription('Sort order') + .setChoices(SORT_CHOICES) + .setRequired(false) + ) ) - .addSubcommand((sub) => - sub.setName('listings') - .setDescription('View your active market listings') + .addSubcommand((sub) => sub + .setName('listings') + .setDescription('View your active market listings') ) - .addSubcommand((sub) => - sub.setName('sell') - .setDescription('Select an item from your inventory to list on the market') + .addSubcommand((sub) => sub + .setName('sell') + .setDescription( + 'Select an item from your inventory to list on the market' + ) ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { const sub = interaction.options.getSubcommand(true); const discordId = interaction.user.id; switch (sub) { - case 'browse': return this.handleBrowse(interaction, discordId); - case 'listings': return this.handleListings(interaction, discordId); - case 'sell': return this.handleSell(interaction, discordId); + case 'browse': + return this.handleBrowse(interaction, discordId); + case 'listings': + return this.handleListings(interaction, discordId); + case 'sell': + return this.handleSell(interaction, discordId); } } - private async handleBrowse(interaction: ChatInputCommandInteraction, discordId: string): Promise { + private async handleBrowse( + interaction: ChatInputCommandInteraction, + discordId: string + ): Promise { await interaction.deferReply(); const search = interaction.options.getString('search') ?? ''; @@ -81,32 +134,65 @@ export default class MarketCommand extends SlashCommand { const sort = interaction.options.getString('sort') ?? 'newest'; try { - const res = await apiFetch(Routes.marketBrowse(discordId, { page: 1, search: search || undefined, rarity, type, sort })); + const res = await apiFetch( + Routes.marketBrowse(discordId, { + page: 1, + search: search || undefined, + rarity, + type, + sort + }) + ); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load market') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load market') + }); return; } const listings: MarketListing[] = body.data; const pagination = body.pagination; - const config: MarketPageConfig = { page: pagination.page, totalPages: pagination.totalPages, totalItems: pagination.totalItems, mode: 'browse' }; + const config: MarketPageConfig = { + page: pagination.page, + totalPages: pagination.totalPages, + totalItems: pagination.totalItems, + mode: 'browse' + }; const imageBuffer = await ImageService.market(listings, config); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'market.png' }); - const embed = new EmbedBuilder().setColor(0x10b981).setImage('attachment://market.png'); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'market.png' + }); + const embed = new EmbedBuilder() + .setColor(0x10b981) + .setImage('attachment://market.png'); const filterKey = `${(search || '').slice(0, 30)}|${rarity}|${type}|${sort}`; - const components = buildMarketButtons(listings, config, filterKey, 'browse'); + const components = buildMarketButtons( + listings, + config, + filterKey, + 'browse' + ); - await interaction.editReply({ embeds: [embed], files: [attachment], components }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - private async handleListings(interaction: ChatInputCommandInteraction, discordId: string): Promise { + private async handleListings( + interaction: ChatInputCommandInteraction, + discordId: string + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { @@ -114,46 +200,73 @@ export default class MarketCommand extends SlashCommand { const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load your listings') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load your listings') + }); return; } const listings: MarketListing[] = body.data; const pagination = body.pagination; - const config: MarketPageConfig = { page: pagination.page, totalPages: pagination.totalPages, totalItems: pagination.totalItems, mode: 'my_listings' }; + const config: MarketPageConfig = { + page: pagination.page, + totalPages: pagination.totalPages, + totalItems: pagination.totalItems, + mode: 'my_listings' + }; const imageBuffer = await ImageService.market(listings, config); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'my_listings.png' }); - const embed = new EmbedBuilder().setColor(0x3b82f6).setImage('attachment://my_listings.png'); - - const components = buildMarketButtons(listings, config, '', 'my_listings'); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'my_listings.png' + }); + const embed = new EmbedBuilder() + .setColor(0x3b82f6) + .setImage('attachment://my_listings.png'); + + const components = buildMarketButtons( + listings, + config, + '', + 'my_listings' + ); - await interaction.editReply({ embeds: [embed], files: [attachment], components }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - private async handleSell(interaction: ChatInputCommandInteraction, discordId: string): Promise { + private async handleSell( + interaction: ChatInputCommandInteraction, + discordId: string + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); try { const result = await buildSellPage(discordId, 0); await interaction.editReply(result); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } } /** * Builds a paginated sell page with select menu + prev/next buttons. * Shared by MarketCommand.handleSell and MarketSellPageButton. */ -export async function buildSellPage(discordId: string, page: number): Promise<{ content: string; components: ActionRowBuilder[] }> { +export async function buildSellPage( + discordId: string, + page: number +): Promise<{ content: string; components: ActionRowBuilder[] }> { const res = await apiFetch(Routes.inventory(discordId)); const body = await res.json(); @@ -164,7 +277,10 @@ export async function buildSellPage(discordId: string, page: number): Promise<{ const inventory: IInventoryItem[] = body.data?.inventory || []; if (inventory.length === 0) { - return { content: '๐ŸŽ’ Your inventory is empty โ€” nothing to sell!', components: [] }; + return { + content: '๐ŸŽ’ Your inventory is empty โ€” nothing to sell!', + components: [] + }; } const sellable = inventory.filter((inv: IInventoryItem) => { @@ -175,12 +291,19 @@ export async function buildSellPage(discordId: string, page: number): Promise<{ }); if (sellable.length === 0) { - return { content: 'โŒ No sellable items. Unlock or acquire non-consumable items first.', components: [] }; + return { + content: + 'โŒ No sellable items. Unlock or acquire non-consumable items first.', + components: [] + }; } const totalPages = Math.ceil(sellable.length / SELL_PAGE_SIZE); const safePage = Math.max(0, Math.min(page, totalPages - 1)); - const pageItems = sellable.slice(safePage * SELL_PAGE_SIZE, (safePage + 1) * SELL_PAGE_SIZE); + const pageItems = sellable.slice( + safePage * SELL_PAGE_SIZE, + (safePage + 1) * SELL_PAGE_SIZE + ); const options: StringSelectMenuOptionBuilder[] = []; for (const inv of pageItems) { @@ -188,13 +311,18 @@ export async function buildSellPage(discordId: string, page: number): Promise<{ if (!def) continue; const enhTag = inv.enhanceLevel > 0 ? ` +${inv.enhanceLevel}` : ''; - const modTag = (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) ? ' โœจ' : ''; + const modTag = + inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides + ? ' โœจ' + : ''; const value = def.value || 0; options.push( new StringSelectMenuOptionBuilder() .setLabel(`${def.name}${enhTag} (x${inv.quantity})${modTag}`) - .setDescription(`${def.rarity} ${def.type} โ€ข Lvl ${def.level} โ€ข Base: ${value.toLocaleString()}g`) + .setDescription( + `${def.rarity} ${def.type} โ€ข Lvl ${def.level} โ€ข Base: ${value.toLocaleString()}g` + ) .setValue(`${inv._id}:${inv.itemId}:${inv.quantity}`) ); } @@ -209,7 +337,9 @@ export async function buildSellPage(discordId: string, page: number): Promise<{ .setMaxValues(1) .addOptions(options); - components.push(new ActionRowBuilder().setComponents(selectMenu)); + components.push( + new ActionRowBuilder().setComponents(selectMenu) + ); } if (totalPages > 1) { @@ -228,14 +358,15 @@ export async function buildSellPage(discordId: string, page: number): Promise<{ .setCustomId(`mkt_sell_page:${safePage + 1}`) .setLabel('Next โ–ถ') .setStyle(ButtonStyle.Secondary) - .setDisabled(safePage >= totalPages - 1), + .setDisabled(safePage >= totalPages - 1) ); components.push(navRow); } - const header = totalPages > 1 - ? `๐Ÿช **Select an item to sell** (Page ${safePage + 1}/${totalPages} โ€ข ${sellable.length} items)` - : `๐Ÿช **Select an item to sell** (${sellable.length} items)`; + const header = + totalPages > 1 + ? `๐Ÿช **Select an item to sell** (Page ${safePage + 1}/${totalPages} โ€ข ${sellable.length} items)` + : `๐Ÿช **Select an item to sell** (${sellable.length} items)`; return { content: header, components }; } @@ -257,9 +388,8 @@ function buildMarketButtons( for (const chunk of chunks.slice(0, 2)) { const row = new ActionRowBuilder(); - for (let i = 0; i < chunk.length; i++) { - const globalIndex = listings.indexOf(chunk[i]); - const listing = chunk[i]; + for (const listing of chunk) { + const globalIndex = listings.indexOf(listing); if (isBrowse) { row.addComponents( @@ -293,18 +423,10 @@ function buildMarketButtons( .setCustomId(`mkt_next:${config.page}:${filterKey}:${mode}`) .setLabel('Next โ–ถ') .setStyle(ButtonStyle.Secondary) - .setDisabled(config.page >= config.totalPages), + .setDisabled(config.page >= config.totalPages) ); rows.push(navRow); } return rows; } - -function chunkArray(arr: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - chunks.push(arr.slice(i, i + size)); - } - return chunks; -} diff --git a/src/commands/NetworkCommand.ts b/src/commands/NetworkCommand.ts index ab167b1..040850b 100644 --- a/src/commands/NetworkCommand.ts +++ b/src/commands/NetworkCommand.ts @@ -1,13 +1,13 @@ -import { - ChatInputCommandInteraction, - Client, - Colors, - EmbedBuilder, - MessageFlags, +import { + type ChatInputCommandInteraction, + type Client, + Colors, + EmbedBuilder, + MessageFlags, ContainerBuilder -} from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import PaginatorBuilder from "../utilities/PaginatorBuilder"; +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import PaginatorBuilder from '../utilities/PaginatorBuilder'; interface ClusterInfo { id: number; @@ -31,144 +31,194 @@ interface GuildInfo { const options = [ { name: 'Overview', value: 'overview' }, { name: 'Cluster', value: 'shard' }, - { name: 'Guild', value: 'guild' }, + { name: 'Guild', value: 'guild' } ]; export default class NetworkCommand extends SlashCommand { constructor() { - super('network', 'Track all clusters and guilds the bot is connected to', 'Moderator'); - - this.data.addStringOption((o) => o.setName('type').setDescription('Select a view type').setChoices(options).setRequired(true)); - this.data.addStringOption((o) => o.setName('id').setDescription("Enter a Cluster ID or Guild ID. Use 'all' to view everything.").setRequired(false)); + super({ + name: 'network', + description: 'Track all clusters and guilds the bot is connected to', + category: 'Moderator', + cooldown: 5, + isGlobalCommand: false + }); + + this.builder.addStringOption((o) => o + .setName('type') + .setDescription('Select a view type') + .setChoices(options) + .setRequired(true) + ); + this.builder.addStringOption((o) => o + .setName('id') + .setDescription( + "Enter a Cluster ID or Guild ID. Use 'all' to view everything." + ) + .setRequired(false) + ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const choice = interaction.options.getString('type', true); const id = interaction.options.getString('id') || 'all'; switch (choice) { - case 'overview': { - const clusters = await this.getClusters(client); - const totalGuilds = clusters.reduce((acc, s) => acc + s.guilds, 0); - const totalUsers = clusters.reduce((acc, s) => acc + s.users, 0); - const avgPing = clusters.reduce((acc, s) => acc + s.ping, 0) / clusters.length; - const totalShards = clusters.reduce((acc, s) => acc + s.shardCount, 0); - - const container = new ContainerBuilder() - .setAccentColor(Colors.Blurple) - .addTextDisplayComponents(text => text.setContent(`# ๐ŸŒ Global Network Overview`)) - .addSeparatorComponents(sep => sep.setDivider(true)) - .addTextDisplayComponents(text => text.setContent( - `**Clusters:** \`${clusters.length}\`\n**Total Shards:** \`${totalShards}\`\n**Total Guilds:** \`${totalGuilds.toLocaleString()}\`\n**Total Users:** \`${totalUsers.toLocaleString()}\`\n**Average Latency:** \`${Math.round(avgPing)}ms\`` - )); - - await interaction.editReply({ components: [container], flags: MessageFlags.IsComponentsV2 }); - break; - } - - case 'shard': { - const clusters = await this.getClusters(client); + case 'overview': { + const clusters = await this.getClusters(client); + const totalGuilds = clusters.reduce((acc, s) => acc + s.guilds, 0); + const totalUsers = clusters.reduce((acc, s) => acc + s.users, 0); + const avgPing = + clusters.reduce((acc, s) => acc + s.ping, 0) / clusters.length; + const totalShards = clusters.reduce((acc, s) => acc + s.shardCount, 0); + + const container = new ContainerBuilder() + .setAccentColor(Colors.Blurple) + .addTextDisplayComponents((text) => text.setContent(`# ๐ŸŒ Global Network Overview`) + ) + .addSeparatorComponents((sep) => sep.setDivider(true)) + .addTextDisplayComponents((text) => text.setContent( + `**Clusters:** \`${clusters.length}\`\n**Total Shards:** \`${totalShards}\`\n**Total Guilds:** \`${totalGuilds.toLocaleString()}\`\n**Total Users:** \`${totalUsers.toLocaleString()}\`\n**Average Latency:** \`${Math.round(avgPing)}ms\`` + ) + ); + + await interaction.editReply({ + components: [container], + flags: MessageFlags.IsComponentsV2 + }); + break; + } - if (id.toLowerCase() === 'all') { - const ITEMS_PER_PAGE = 10; - const pages: EmbedBuilder[] = []; + case 'shard': { + const clusters = await this.getClusters(client); - for (let i = 0; i < clusters.length; i += ITEMS_PER_PAGE) { - const chunk = clusters.slice(i, i + ITEMS_PER_PAGE); - let descriptionText = ''; + if (id.toLowerCase() === 'all') { + const ITEMS_PER_PAGE = 10; + const pages: EmbedBuilder[] = []; - for (const cluster of chunk) { - descriptionText += `๐Ÿ’Ž **Cluster #${cluster.id}** | **Ping:** \`${cluster.ping}ms\` | **Shards:** \`${cluster.shards.join(', ')}\`\n`; - descriptionText += `โ”” **Guilds:** \`${cluster.guilds.toLocaleString()}\` | **Users:** \`${cluster.users.toLocaleString()}\`\n\n`; - } + for (let i = 0; i < clusters.length; i += ITEMS_PER_PAGE) { + const chunk = clusters.slice(i, i + ITEMS_PER_PAGE); + let descriptionText = ''; - pages.push(new EmbedBuilder().setColor(Colors.Blue).setTitle('Network Manager: Clusters').setDescription(descriptionText)); + for (const cluster of chunk) { + descriptionText += `๐Ÿ’Ž **Cluster #${cluster.id}** | **Ping:** \`${cluster.ping}ms\` | **Shards:** \`${cluster.shards.join(', ')}\`\n`; + descriptionText += `โ”” **Guilds:** \`${cluster.guilds.toLocaleString()}\` | **Users:** \`${cluster.users.toLocaleString()}\`\n\n`; } - const paginator = new PaginatorBuilder() - .setPages(pages) - .setTargetUser(interaction.user.id) - .setIdleTimeout(60_000); + pages.push( + new EmbedBuilder() + .setColor(Colors.Blue) + .setTitle('Network Manager: Clusters') + .setDescription(descriptionText) + ); + } - await paginator.start(interaction); + const paginator = new PaginatorBuilder() + .setPages(pages) + .setTargetUser(interaction.user.id) + .setIdleTimeout(60_000); + + await paginator.start(interaction); + } else { + const targetCluster = clusters.find((s) => s.id.toString() === id); + if (!targetCluster) { + await interaction.editReply({ + content: `โŒ No cluster could be found with the ID: \`${id}\`` + }); return; - } else { - const targetCluster = clusters.find(s => s.id.toString() === id); - if (!targetCluster) { - await interaction.editReply({ content: `โŒ No cluster could be found with the ID: \`${id}\`` }); - return; - } - - const uptimeMins = Math.floor((targetCluster.uptime || 0) / 60000); - const uptimeHours = Math.floor(uptimeMins / 60); + } - const container = new ContainerBuilder() - .setAccentColor(Colors.Blue) - .addTextDisplayComponents(text => text.setContent(`# ๐Ÿ’Ž Cluster #${targetCluster.id}`)) - .addSeparatorComponents(sep => sep.setDivider(true)) - .addTextDisplayComponents(text => text.setContent( - `**Status:** \`Online\`\n**Ping:** \`${targetCluster.ping}ms\`\n**Internal Shards:** \`${targetCluster.shards.join(', ')}\`\n**Guilds Hosted:** \`${targetCluster.guilds.toLocaleString()}\`\n**Users Tracked:** \`${targetCluster.users.toLocaleString()}\`\n**Uptime:** \`${uptimeHours}h ${uptimeMins % 60}m\`` - )); + const uptimeMins = Math.floor((targetCluster.uptime || 0) / 60000); + const uptimeHours = Math.floor(uptimeMins / 60); - await interaction.editReply({ components: [container], flags: MessageFlags.IsComponentsV2 }); - } - break; + const container = new ContainerBuilder() + .setAccentColor(Colors.Blue) + .addTextDisplayComponents((text) => text.setContent(`# ๐Ÿ’Ž Cluster #${targetCluster.id}`) + ) + .addSeparatorComponents((sep) => sep.setDivider(true)) + .addTextDisplayComponents((text) => text.setContent( + `**Status:** \`Online\`\n**Ping:** \`${targetCluster.ping}ms\`\n**Internal Shards:** \`${targetCluster.shards.join(', ')}\`\n**Guilds Hosted:** \`${targetCluster.guilds.toLocaleString()}\`\n**Users Tracked:** \`${targetCluster.users.toLocaleString()}\`\n**Uptime:** \`${uptimeHours}h ${uptimeMins % 60}m\`` + ) + ); + + await interaction.editReply({ + components: [container], + flags: MessageFlags.IsComponentsV2 + }); } + break; + } - case 'guild': { - const guilds = await this.getGuilds(client); - - if (id.toLowerCase() === 'all') { - const ITEMS_PER_PAGE = 10; - const pages: EmbedBuilder[] = []; + case 'guild': { + const guilds = await this.getGuilds(client); - for (let i = 0; i < guilds.length; i += ITEMS_PER_PAGE) { - const chunk = guilds.slice(i, i + ITEMS_PER_PAGE); - let descriptionText = ''; + if (id.toLowerCase() === 'all') { + const ITEMS_PER_PAGE = 10; + const pages: EmbedBuilder[] = []; - for (const guild of chunk) { - descriptionText += `๐Ÿ›ก๏ธ **${guild.name}** (ID: \`${guild.id}\`)\n`; - descriptionText += `โ”” **Cluster:** \`${guild.clusterId}\` | **Members:** \`${guild.memberCount.toLocaleString()}\`\n\n`; - } + for (let i = 0; i < guilds.length; i += ITEMS_PER_PAGE) { + const chunk = guilds.slice(i, i + ITEMS_PER_PAGE); + let descriptionText = ''; - pages.push(new EmbedBuilder().setColor(Colors.Purple).setTitle('Network Manager: Guilds').setDescription(descriptionText)); + for (const guild of chunk) { + descriptionText += `๐Ÿ›ก๏ธ **${guild.name}** (ID: \`${guild.id}\`)\n`; + descriptionText += `โ”” **Cluster:** \`${guild.clusterId}\` | **Members:** \`${guild.memberCount.toLocaleString()}\`\n\n`; } - const paginator = new PaginatorBuilder() - .setPages(pages) - .setTargetUser(interaction.user.id) - .setIdleTimeout(60_000); + pages.push( + new EmbedBuilder() + .setColor(Colors.Purple) + .setTitle('Network Manager: Guilds') + .setDescription(descriptionText) + ); + } - await paginator.start(interaction); + const paginator = new PaginatorBuilder() + .setPages(pages) + .setTargetUser(interaction.user.id) + .setIdleTimeout(60_000); + + await paginator.start(interaction); + } else { + const targetGuild = guilds.find((g) => g.id === id); + if (!targetGuild) { + await interaction.editReply({ + content: `โŒ No guild could be found with the ID: \`${id}\`` + }); return; - } else { - const targetGuild = guilds.find(g => g.id === id); - if (!targetGuild) { - await interaction.editReply({ content: `โŒ No guild could be found with the ID: \`${id}\`` }); - return; - } - - const joinedTimestamp = targetGuild.joinedAt ? `` : 'Unknown'; + } - const container = new ContainerBuilder() - .setAccentColor(Colors.Purple) - .addTextDisplayComponents(text => text.setContent(`# ๐Ÿ›ก๏ธ Guild Details\n**${targetGuild.name}**`)) - .addSeparatorComponents(sep => sep.setDivider(true)) - .addTextDisplayComponents(text => text.setContent( - `**Guild ID:** \`${targetGuild.id}\`\n**Hosted on Cluster:** \`${targetGuild.clusterId}\`\n**Total Members:** \`${targetGuild.memberCount.toLocaleString()}\`\n**Owner ID:** \`${targetGuild.ownerId}\`\n**Joined Bot:** ${joinedTimestamp}` - )); + const joinedTimestamp = targetGuild.joinedAt + ? `` + : 'Unknown'; - await interaction.editReply({ components: [container], flags: MessageFlags.IsComponentsV2 }); - } - break; + const container = new ContainerBuilder() + .setAccentColor(Colors.Purple) + .addTextDisplayComponents((text) => text.setContent(`# ๐Ÿ›ก๏ธ Guild Details\n**${targetGuild.name}**`) + ) + .addSeparatorComponents((sep) => sep.setDivider(true)) + .addTextDisplayComponents((text) => text.setContent( + `**Guild ID:** \`${targetGuild.id}\`\n**Hosted on Cluster:** \`${targetGuild.clusterId}\`\n**Total Members:** \`${targetGuild.memberCount.toLocaleString()}\`\n**Owner ID:** \`${targetGuild.ownerId}\`\n**Joined Bot:** ${joinedTimestamp}` + ) + ); + + await interaction.editReply({ + components: [container], + flags: MessageFlags.IsComponentsV2 + }); } + break; + } } } // --- HELPER METHODS --- - + /** * Reaches across all clusters via hybrid sharding broadcastEval. */ @@ -181,21 +231,28 @@ export default class NetworkCommand extends SlashCommand { shardCount: c.ws.shards?.size ?? 1, ping: c.ws.ping, guilds: c.guilds.cache.size, - users: c.guilds.cache.reduce((acc: number, guild: any) => acc + guild.memberCount, 0), + users: c.guilds.cache.reduce( + (acc: number, guild: any) => acc + guild.memberCount, + 0 + ), uptime: c.uptime })); return results; - } else { - return [{ + } + return [ + { id: 0, shards: [0], shardCount: 1, ping: client.ws.ping, guilds: client.guilds.cache.size, - users: client.guilds.cache.reduce((acc, guild) => acc + guild.memberCount, 0), + users: client.guilds.cache.reduce( + (acc, guild) => acc + guild.memberCount, + 0 + ), uptime: client.uptime - }]; - } + } + ]; } /** @@ -204,34 +261,24 @@ export default class NetworkCommand extends SlashCommand { private async getGuilds(client: Client): Promise { const cluster = (client as any).cluster; if (cluster) { - const results: GuildInfo[][] = await cluster.broadcastEval((c: any) => - c.guilds.cache.map((g: any) => ({ - id: g.id, - name: g.name, - memberCount: g.memberCount, - clusterId: c.cluster?.id ?? 0, - ownerId: g.ownerId, - joinedAt: g.joinedTimestamp - })) - ); - return results.flat(); - } else { - return client.guilds.cache.map(g => ({ + const results: GuildInfo[][] = await cluster.broadcastEval((c: any) => c.guilds.cache.map((g: any) => ({ id: g.id, name: g.name, memberCount: g.memberCount, - clusterId: 0, + clusterId: c.cluster?.id ?? 0, ownerId: g.ownerId, joinedAt: g.joinedTimestamp - })); + })) + ); + return results.flat(); } - } - - public isGlobalCommand(): boolean { - return false; - } - - public cooldown(): number { - return 5; + return client.guilds.cache.map((g) => ({ + id: g.id, + name: g.name, + memberCount: g.memberCount, + clusterId: 0, + ownerId: g.ownerId, + joinedAt: g.joinedTimestamp + })); } } diff --git a/src/commands/ProfileCommand.ts b/src/commands/ProfileCommand.ts index db566aa..dfd17ca 100644 --- a/src/commands/ProfileCommand.ts +++ b/src/commands/ProfileCommand.ts @@ -1,33 +1,57 @@ -import { ChatInputCommandInteraction, Client, AttachmentBuilder, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { IPlayerJSON } from "../interfaces/IPlayerJSON"; -import { IInventoryItem } from "../interfaces/IInventoryJSON"; -import { ICollectionJSON } from "../interfaces/ICollectionJSON"; -import Routes from "../utilities/Routes"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import { EquipmentSlot } from "../interfaces/IItemJSON"; -import ImageService from "../utilities/ImageService"; +import { + type ChatInputCommandInteraction, + type Client, + AttachmentBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ButtonBuilder, + ButtonStyle +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { type IPlayerJSON } from '../interfaces/IPlayerJSON'; +import { type IInventoryItem } from '../interfaces/IInventoryJSON'; +import { type ICollectionJSON } from '../interfaces/ICollectionJSON'; +import * as Routes from '../utilities/Routes'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import { type EquipmentSlot } from '../interfaces/IItemJSON'; +import * as ImageService from '../utilities/ImageService'; export default class ProfileCommand extends SlashCommand { constructor() { - super('profile', 'View your or another player\'s profile', 'General'); - this.data.addUserOption((o) => o.setName('user').setDescription('Select a user').setRequired(false)); + super({ + name: 'profile', + description: "View your or another player's profile", + category: 'General', + cooldown: 5, + isGlobalCommand: true + }); + this.builder.addUserOption((o) => o.setName('user').setDescription('Select a user').setRequired(false) + ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); - const targetUser = interaction.options.getUser('user', false) ?? interaction.user; + const targetUser = + interaction.options.getUser('user', false) ?? interaction.user; const res = await apiFetch(Routes.player(targetUser.id)); if (res.status === 404) { - await interaction.editReply({ content: formatError('', 'PLAYER_NOT_FOUND') }); + await interaction.editReply({ + content: formatError('', 'PLAYER_NOT_FOUND') + }); return; } if (!res.ok) { - await interaction.editReply({ content: formatError('Failed to load profile') }); + await interaction.editReply({ + content: formatError('Failed to load profile') + }); return; } @@ -39,7 +63,9 @@ export default class ProfileCommand extends SlashCommand { // Generate the canvas profile image const imageBuffer = await ImageService.profile(player, targetUser); - const profileAttachment = new AttachmentBuilder(imageBuffer, { name: 'profile.png' }); + const profileAttachment = new AttachmentBuilder(imageBuffer, { + name: 'profile.png' + }); // Only show interactive components for your OWN profile if (targetUser.id === interaction.user.id) { @@ -47,20 +73,33 @@ export default class ProfileCommand extends SlashCommand { const options: StringSelectMenuOptionBuilder[] = []; const equipment = player.equipment; - Object.entries(equipment).forEach(entry => { + Object.entries(equipment).forEach((entry) => { const slot = entry[0] as EquipmentSlot; const itemId = entry[1]; if (itemId) { - options.push(new StringSelectMenuOptionBuilder().setLabel(slot).setValue(slot)); + options.push( + new StringSelectMenuOptionBuilder().setLabel(slot).setValue(slot) + ); } }); - const menu = new StringSelectMenuBuilder().setCustomId('unequip') - .setOptions(options.length >= 1 ? options : [new StringSelectMenuOptionBuilder().setLabel('None').setValue('None')]) + const menu = new StringSelectMenuBuilder() + .setCustomId('unequip') + .setOptions( + options.length >= 1 + ? options + : [ + new StringSelectMenuOptionBuilder() + .setLabel('None') + .setValue('None') + ] + ) .setMaxValues(1) .setPlaceholder('Unequip Slot'); - components.push(new ActionRowBuilder().setComponents(menu)); + components.push( + new ActionRowBuilder().setComponents(menu) + ); // Skill points button (only if they have unspent points) if (player.skillPoints > 0) { @@ -69,16 +108,15 @@ export default class ProfileCommand extends SlashCommand { .setLabel(`โญ Spend Skill Points (${player.skillPoints} available)`) .setStyle(ButtonStyle.Primary); - components.push(new ActionRowBuilder().setComponents(spButton)); + components.push( + new ActionRowBuilder().setComponents(spButton) + ); } } await interaction.editReply({ files: [profileAttachment], - components, + components }); } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } -} \ No newline at end of file +} diff --git a/src/commands/RegisterCommand.ts b/src/commands/RegisterCommand.ts index 8b69576..a33bf33 100644 --- a/src/commands/RegisterCommand.ts +++ b/src/commands/RegisterCommand.ts @@ -1,25 +1,42 @@ -import { ChatInputCommandInteraction, Client, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; +import { + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; export default class RegisterCommand extends SlashCommand { constructor() { - super('register', 'Register new user data with the bot', 'General'); + super({ + name: 'register', + description: 'Register new user data with the bot', + category: 'General', + cooldown: 5, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { // Show the consent prompt โ€” registration happens when they click Accept const embed = new EmbedBuilder() .setColor(0x10b981) - .setTitle('โš”๏ธ Welcome to Dragon\'s Fall Online') + .setTitle("โš”๏ธ Welcome to Dragon's Fall Online") .setDescription( 'Before creating your character, please review the following:\n\n' + - '**What we store:**\n' + - '> Your Discord user ID, username, and avatar are used to create your player profile. ' + - 'Gameplay data (level, inventory, stats) is stored on our servers.\n\n' + - '**Your rights:**\n' + - '> You may request full deletion of your player data at any time by contacting the developer.\n\n' + - '**By clicking Accept**, you agree to our [Privacy Policy & Terms of Service](https://capi.gg/legal).\n\n' + - '-# You can review our full legal page at any time: https://capi.gg/legal' + '**What we store:**\n' + + '> Your Discord user ID, username, and avatar are used to create your player profile. ' + + 'Gameplay data (level, inventory, stats) is stored on our servers.\n\n' + + '**Your rights:**\n' + + '> You may request full deletion of your player data at any time by contacting the developer.\n\n' + + '**By clicking Accept**, you agree to our [Privacy Policy & Terms of Service](https://capi.gg/legal).\n\n' + + '-# You can review our full legal page at any time: https://capi.gg/legal' ) .setFooter({ text: 'DFO Cross-Platform Integration' }); @@ -37,17 +54,13 @@ export default class RegisterCommand extends SlashCommand { .setLabel('Privacy Policy & ToS') .setStyle(ButtonStyle.Link) .setURL('https://capi.gg/legal') - .setEmoji('๐Ÿ“œ'), + .setEmoji('๐Ÿ“œ') ); - await interaction.reply({ embeds: [embed], components: [row], flags: MessageFlags.Ephemeral }); + await interaction.reply({ + embeds: [embed], + components: [row], + flags: MessageFlags.Ephemeral + }); } - - public isGlobalCommand(): boolean { - return true; - } - - public cooldown(): number { - return 5; - } -} \ No newline at end of file +} diff --git a/src/commands/RestCommand.ts b/src/commands/RestCommand.ts index bc6967a..f558419 100644 --- a/src/commands/RestCommand.ts +++ b/src/commands/RestCommand.ts @@ -1,33 +1,49 @@ -import { ChatInputCommandInteraction, Client } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; +import type { ChatInputCommandInteraction, Client } from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; export default class RestCommand extends SlashCommand { constructor() { - super('rest', 'Rest at the inn to restore HP (costs gold)', 'Gaming'); + super({ + name: 'rest', + description: 'Rest at the inn to restore HP (costs gold)', + category: 'Gaming', + cooldown: 5, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); try { // Go straight to POST โ€” the endpoint returns appropriate errors for full HP, no gold, etc. const res = await apiFetch(Routes.rest(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id }), + body: JSON.stringify({ discordId: interaction.user.id }) }); const result = await res.json(); if (!res.ok || !result.success) { // Handle full HP case gracefully - if (result.error?.includes('full') || result.error?.includes('already')) { - await interaction.editReply({ content: 'โค๏ธ You are already at full HP!' }); + if ( + result.error?.includes('full') || + result.error?.includes('already') + ) { + await interaction.editReply({ + content: 'โค๏ธ You are already at full HP!' + }); return; } - await interaction.editReply({ content: formatError(result.error ?? 'Rest failed') }); + await interaction.editReply({ + content: formatError(result.error ?? 'Rest failed') + }); return; } @@ -37,14 +53,13 @@ export default class RestCommand extends SlashCommand { ``, `โค๏ธ Restored **${result.healedAmount?.toLocaleString() ?? '???'} HP** โ†’ ${result.newHp?.toLocaleString() ?? '???'} / ${result.maxHp?.toLocaleString() ?? '???'}`, `๐Ÿช™ Cost: **${result.goldSpent?.toLocaleString() ?? '???'}** Gold`, - `๐Ÿ’ฐ Balance: **${result.newBalance?.toLocaleString() ?? '???'}** Gold`, - ].join('\n'), + `๐Ÿ’ฐ Balance: **${result.newBalance?.toLocaleString() ?? '???'}** Gold` + ].join('\n') }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/commands/TasksCommand.ts b/src/commands/TasksCommand.ts index 41909be..5952e76 100644 --- a/src/commands/TasksCommand.ts +++ b/src/commands/TasksCommand.ts @@ -1,31 +1,46 @@ import { - ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, - ChatInputCommandInteraction, Client, EmbedBuilder, StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; -import ImageService from "../utilities/ImageService"; -import type { ITaskJSON } from "../interfaces/IGameJSON"; + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + type StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; +import * as ImageService from '../utilities/ImageService'; +import type { ITaskJSON } from '../interfaces/IGameJSON'; export default class TasksCommand extends SlashCommand { constructor() { - super('tasks', 'View your active tasks and claim rewards', 'Gaming'); - this.data.addStringOption((o) => - o.setName('period') - .setDescription('Task period to view') - .setRequired(false) - .addChoices( - { name: 'Daily', value: 'daily' }, - { name: 'Weekly', value: 'weekly' }, - { name: 'Monthly', value: 'monthly' }, - ) + super({ + name: 'tasks', + description: 'View your active tasks and claim rewards', + category: 'Gaming', + cooldown: 5, + isGlobalCommand: true + }); + this.builder.addStringOption((o) => o + .setName('period') + .setDescription('Task period to view') + .setRequired(false) + .addChoices( + { name: 'Daily', value: 'daily' }, + { name: 'Weekly', value: 'weekly' }, + { name: 'Monthly', value: 'monthly' } + ) ); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const period = interaction.options.getString('period') ?? 'daily'; const discordId = interaction.user.id; @@ -35,7 +50,9 @@ export default class TasksCommand extends SlashCommand { const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load tasks') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load tasks') + }); return; } @@ -48,24 +65,38 @@ export default class TasksCommand extends SlashCommand { // Convert ISO reset string to ms remaining const resetIso = resets[period]; - const resetIn = resetIso ? Math.max(0, new Date(resetIso).getTime() - Date.now()) : 0; + const resetIn = resetIso + ? Math.max(0, new Date(resetIso).getTime() - Date.now()) + : 0; // Build canvas image const imageBuffer = await ImageService.tasks(tasks, { period, resetIn, - playerEmbers, + playerEmbers }); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'tasks.png' }); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'tasks.png' + }); const embed = new EmbedBuilder() - .setColor(period === 'daily' ? 0x10b981 : period === 'weekly' ? 0x6366f1 : 0xc026d3) + .setColor( + period === 'daily' + ? 0x10b981 + : period === 'weekly' + ? 0x6366f1 + : 0xc026d3 + ) .setImage('attachment://tasks.png'); - const components: ActionRowBuilder[] = []; + const components: ActionRowBuilder< + ButtonBuilder | StringSelectMenuBuilder + >[] = []; // Claim buttons for completed unclaimed tasks - const claimable = tasks.filter((t: ITaskJSON) => t.progress >= t.target && !t.claimed); + const claimable = tasks.filter( + (t: ITaskJSON) => t.progress >= t.target && !t.claimed + ); if (claimable.length > 0) { const claimRow = new ActionRowBuilder(); for (const task of claimable.slice(0, 5)) { @@ -84,27 +115,36 @@ export default class TasksCommand extends SlashCommand { new ButtonBuilder() .setCustomId(`tasks_tab:daily`) .setLabel('Daily') - .setStyle(period === 'daily' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setStyle( + period === 'daily' ? ButtonStyle.Primary : ButtonStyle.Secondary + ) .setDisabled(period === 'daily'), new ButtonBuilder() .setCustomId(`tasks_tab:weekly`) .setLabel('Weekly') - .setStyle(period === 'weekly' ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setStyle( + period === 'weekly' ? ButtonStyle.Primary : ButtonStyle.Secondary + ) .setDisabled(period === 'weekly'), new ButtonBuilder() .setCustomId(`tasks_tab:monthly`) .setLabel('Monthly') - .setStyle(period === 'monthly' ? ButtonStyle.Primary : ButtonStyle.Secondary) - .setDisabled(period === 'monthly'), + .setStyle( + period === 'monthly' ? ButtonStyle.Primary : ButtonStyle.Secondary + ) + .setDisabled(period === 'monthly') ); components.push(periodRow); - await interaction.editReply({ embeds: [embed], files: [attachment], components: components as any }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components: components as any + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/commands/TestCommand.ts b/src/commands/TestCommand.ts index 35f11c9..7a12e73 100644 --- a/src/commands/TestCommand.ts +++ b/src/commands/TestCommand.ts @@ -1,18 +1,35 @@ -import { ButtonBuilder, ButtonStyle, ChannelType, ChatInputCommandInteraction, Client, ContainerBuilder, MessageFlags } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import logger from "../utilities/Logger"; +import { + ButtonBuilder, + ButtonStyle, + ChannelType, + type ChatInputCommandInteraction, + type Client, + ContainerBuilder, + MessageFlags +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import logger from '../utilities/Logger'; export default class TestCommand extends SlashCommand { constructor() { - super('test', 'dev command', 'Developer'); + super({ + name: 'test', + description: 'dev command', + category: 'Developer', + cooldown: 5, + isGlobalCommand: false + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); const res = await fetch('https://capi.gg/api/facts/random', { headers: { - 'Authorization': `Bearer ${process.env.API_KEY}`, + Authorization: `Bearer ${process.env.API_KEY}`, 'Content-Type': 'application/json' } }); @@ -28,12 +45,4 @@ export default class TestCommand extends SlashCommand { await interaction.editReply({ content: `\`${factId}\`\n${text}` }); } - - public isGlobalCommand(): boolean { - return false; - } - - public cooldown(): number { - return 5; - } -} \ No newline at end of file +} diff --git a/src/commands/TravelCommand.ts b/src/commands/TravelCommand.ts index 1f83c26..df854f5 100644 --- a/src/commands/TravelCommand.ts +++ b/src/commands/TravelCommand.ts @@ -1,20 +1,39 @@ import { - ActionRowBuilder, AttachmentBuilder, ChatInputCommandInteraction, - Client, EmbedBuilder, MessageFlags, StringSelectMenuBuilder, StringSelectMenuOptionBuilder -} from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; -import { apiFetch } from "../utilities/ApiClient"; -import { formatError } from "../utilities/ErrorMessages"; -import Routes from "../utilities/Routes"; -import { getAccessibleZones, getZone, type ZoneInfo } from "../utilities/ZoneData"; -import ImageService from "../utilities/ImageService"; + ActionRowBuilder, + AttachmentBuilder, + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + MessageFlags, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; +import { apiFetch } from '../utilities/ApiClient'; +import { formatError } from '../utilities/ErrorMessages'; +import * as Routes from '../utilities/Routes'; +import { + getAccessibleZones, + getZone, + type ZoneInfo +} from '../utilities/ZoneData'; +import * as ImageService from '../utilities/ImageService'; export default class TravelCommand extends SlashCommand { constructor() { - super('travel', 'View the zone map and travel to a different zone', 'Gaming'); + super({ + name: 'travel', + description: 'View the zone map and travel to a different zone', + category: 'Gaming', + cooldown: 5, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { await interaction.deferReply(); // Fetch player data to get current zone and level @@ -22,12 +41,16 @@ export default class TravelCommand extends SlashCommand { const res = await apiFetch(Routes.player(interaction.user.id)); if (res.status === 404) { - await interaction.editReply({ content: formatError('', 'PLAYER_NOT_FOUND') }); + await interaction.editReply({ + content: formatError('', 'PLAYER_NOT_FOUND') + }); return; } if (!res.ok) { - await interaction.editReply({ content: formatError('Failed to load player data') }); + await interaction.editReply({ + content: formatError('Failed to load player data') + }); return; } @@ -38,39 +61,55 @@ export default class TravelCommand extends SlashCommand { // Render the zone map const imageBuffer = await ImageService.travel(playerLevel, currentZoneId); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'zonemap.png' }); - const embed = new EmbedBuilder().setColor(0x10b981).setImage('attachment://zonemap.png'); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'zonemap.png' + }); + const embed = new EmbedBuilder() + .setColor(0x10b981) + .setImage('attachment://zonemap.png'); // Build the travel select menu with accessible zones (excluding current) - const accessible = getAccessibleZones(playerLevel).filter(z => z.id !== currentZoneId); + const accessible = getAccessibleZones(playerLevel).filter( + (z) => z.id !== currentZoneId + ); const currentZone = getZone(currentZoneId); const components: ActionRowBuilder[] = []; if (accessible.length > 0) { - const options = accessible.map(zone => - new StringSelectMenuOptionBuilder() - .setLabel(zone.name) - .setDescription(`Lvl ${zone.levelReq}+ โ€ข ${zone.rarityCap} cap โ€ข ${zone.combatChance}% combat`) - .setValue(String(zone.id)) + const options = accessible.map((zone) => new StringSelectMenuOptionBuilder() + .setLabel(zone.name) + .setDescription( + `Lvl ${zone.levelReq}+ โ€ข ${zone.rarityCap} cap โ€ข ${zone.combatChance}% combat` + ) + .setValue(String(zone.id)) ); const selectMenu = new StringSelectMenuBuilder() .setCustomId('travel_select') - .setPlaceholder(`Current: ${currentZone?.name ?? 'Unknown'} โ€” Select destination...`) + .setPlaceholder( + `Current: ${currentZone?.name ?? 'Unknown'} โ€” Select destination...` + ) .setMinValues(1) .setMaxValues(1) .addOptions(options.slice(0, 25)); // Discord max 25 options - components.push(new ActionRowBuilder().setComponents(selectMenu)); + components.push( + new ActionRowBuilder().setComponents( + selectMenu + ) + ); } - await interaction.editReply({ embeds: [embed], files: [attachment], components }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isGlobalCommand(): boolean { return true; } - public cooldown(): number { return 5; } -} \ No newline at end of file +} diff --git a/src/commands/VoteCommand.ts b/src/commands/VoteCommand.ts index 0a5123d..fb81585 100644 --- a/src/commands/VoteCommand.ts +++ b/src/commands/VoteCommand.ts @@ -1,20 +1,36 @@ -import { ChatInputCommandInteraction, Client, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; -import SlashCommand from "../structures/SlashCommand"; +import { + type ChatInputCommandInteraction, + type Client, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} from 'discord.js'; +import SlashCommand from '../structures/SlashCommand'; export default class VoteCommand extends SlashCommand { constructor() { - super('vote', 'Support DFO by voting on top.gg!', 'General'); + super({ + name: 'vote', + description: 'Support DFO by voting on top.gg!', + category: 'General', + cooldown: 5, + isGlobalCommand: true + }); } - public async execute(interaction: ChatInputCommandInteraction, client: Client): Promise { + public async execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise { const botId = client.user?.id ?? ''; const embed = new EmbedBuilder() .setColor(0xff3366) - .setTitle('๐Ÿ—ณ๏ธ Vote for Dragon\'s Fall Online!') + .setTitle("๐Ÿ—ณ๏ธ Vote for Dragon's Fall Online!") .setDescription( 'Voting helps more players discover DFO and keeps the project alive.\n\n' + - 'You can vote every **12 hours** on top.gg. Thank you for your support!' + 'You can vote every **12 hours** on top.gg. Thank you for your support!' ) .setThumbnail(client.user?.displayAvatarURL() ?? ''); @@ -28,17 +44,9 @@ export default class VoteCommand extends SlashCommand { .setLabel('Play on Web') .setStyle(ButtonStyle.Link) .setURL('https://capi.gg/dfo') - .setEmoji('๐ŸŒ'), + .setEmoji('๐ŸŒ') ); await interaction.reply({ embeds: [embed], components: [row] }); } - - public isGlobalCommand(): boolean { - return true; - } - - public cooldown(): number { - return 5; - } -} \ No newline at end of file +} diff --git a/src/components/buttons/AttackButton.ts b/src/components/buttons/AttackButton.ts index 88d0d59..fabfb10 100644 --- a/src/components/buttons/AttackButton.ts +++ b/src/components/buttons/AttackButton.ts @@ -1,15 +1,20 @@ -import { ButtonInteraction, Client, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { ICombatJSON } from "../../interfaces/ICombatJSON"; -import { apiFetch } from "../../utilities/ApiClient"; -import { buildCombatResponse } from "../../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { type ButtonInteraction, type Client, MessageFlags } from 'discord.js'; +import Button from '../../structures/Button'; +import { type ICombatJSON } from '../../interfaces/ICombatJSON'; +import { apiFetch } from '../../utilities/ApiClient'; +import { buildCombatResponse } from '../../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class AttackButton extends Button { - constructor() { super('attack'); } + constructor() { + super({ customId: 'attack', cooldown: 1.8, isAuthorOnly: false }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const res = await apiFetch(Routes.combat(), { @@ -22,7 +27,7 @@ export default class AttackButton extends Button { return; } - const data = await res.json() as ICombatJSON; + const data = (await res.json()) as ICombatJSON; if (data.error) { await interaction.editReply({ content: formatError(data.error) }); @@ -32,7 +37,4 @@ export default class AttackButton extends Button { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isAuthorOnly(): boolean { return false; } - public cooldown(): number { return 1.8; } -} \ No newline at end of file +} diff --git a/src/components/buttons/BulkCollectButton.ts b/src/components/buttons/BulkCollectButton.ts index a948ec8..93560c3 100644 --- a/src/components/buttons/BulkCollectButton.ts +++ b/src/components/buttons/BulkCollectButton.ts @@ -1,17 +1,32 @@ -import { ButtonInteraction, Client, LabelBuilder, MessageFlags, ModalBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextDisplayBuilder } from "discord.js"; -import Button from "../../structures/Button"; -import ItemManager from "../../managers/ItemManager"; -import { apiFetch } from "../../utilities/ApiClient"; -import Routes from "../../utilities/Routes"; -import type { IInventoryItem } from "../../interfaces/IInventoryJSON"; +import { + type ButtonInteraction, + type Client, + LabelBuilder, + MessageFlags, + ModalBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + TextDisplayBuilder +} from 'discord.js'; +import Button from '../../structures/Button'; +import * as ItemManager from '../../managers/ItemManager'; +import { apiFetch } from '../../utilities/ApiClient'; +import * as Routes from '../../utilities/Routes'; +import type { IInventoryItem } from '../../interfaces/IInventoryJSON'; const ITEMS_PER_PAGE = 15; export default class BulkCollectButton extends Button { - constructor() { super('bulk_collect'); } + constructor() { + super({ customId: 'bulk_collect', cooldown: 3, isAuthorOnly: true }); + } // customId format: bulk_collect: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const pageOffset = parseInt(args?.[0] ?? '0', 10); const res = await apiFetch(Routes.inventory(interaction.user.id)); @@ -20,16 +35,20 @@ export default class BulkCollectButton extends Button { const inventory: IInventoryItem[] = data?.inventory || []; const chunk = inventory.slice(pageOffset, pageOffset + ITEMS_PER_PAGE); - const eligible = chunk.filter(inv => { + const eligible = chunk.filter((inv) => { if (inv.isLocked) return false; - if (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) return false; + if (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) + return false; const def = ItemManager.get(inv.itemId); if (!def || def.type === 'Consumable') return false; return true; }); if (eligible.length === 0) { - await interaction.reply({ content: 'โŒ No eligible items to collect on this page.', flags: MessageFlags.Ephemeral}); + await interaction.reply({ + content: 'โŒ No eligible items to collect on this page.', + flags: MessageFlags.Ephemeral + }); return; } @@ -57,11 +76,14 @@ export default class BulkCollectButton extends Button { const selectLabel = new LabelBuilder() .setLabel('Select items to archive') - .setDescription('Selected items move from inventory to your collection book') + .setDescription( + 'Selected items move from inventory to your collection book' + ) .setStringSelectMenuComponent(selectMenu); - const infoText = new TextDisplayBuilder() - .setContent('-# โš ๏ธ THIS IS PERMANENT. Items are removed from your inventory and added to your Collection Book. Modified items will be skipped. This cannot be undone.'); + const infoText = new TextDisplayBuilder().setContent( + '-# โš ๏ธ THIS IS PERMANENT. Items are removed from your inventory and added to your Collection Book. Modified items will be skipped. This cannot be undone.' + ); const modal = new ModalBuilder() .setCustomId('bulk_collect_modal') @@ -71,7 +93,4 @@ export default class BulkCollectButton extends Button { await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/BulkDismantleButton.ts b/src/components/buttons/BulkDismantleButton.ts index a49f072..44d2b93 100644 --- a/src/components/buttons/BulkDismantleButton.ts +++ b/src/components/buttons/BulkDismantleButton.ts @@ -1,17 +1,32 @@ -import { ButtonInteraction, Client, LabelBuilder, MessageFlags, ModalBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextDisplayBuilder } from "discord.js"; -import Button from "../../structures/Button"; -import ItemManager from "../../managers/ItemManager"; -import { apiFetch } from "../../utilities/ApiClient"; -import Routes from "../../utilities/Routes"; -import type { IInventoryItem } from "../../interfaces/IInventoryJSON"; +import { + type ButtonInteraction, + type Client, + LabelBuilder, + MessageFlags, + ModalBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + TextDisplayBuilder +} from 'discord.js'; +import Button from '../../structures/Button'; +import * as ItemManager from '../../managers/ItemManager'; +import { apiFetch } from '../../utilities/ApiClient'; +import * as Routes from '../../utilities/Routes'; +import type { IInventoryItem } from '../../interfaces/IInventoryJSON'; const ITEMS_PER_PAGE = 15; export default class BulkDismantleButton extends Button { - constructor() { super('bulk_dismantle'); } + constructor() { + super({ customId: 'bulk_dismantle', cooldown: 3, isAuthorOnly: true }); + } // customId format: bulk_dismantle: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const pageOffset = parseInt(args?.[0] ?? '0', 10); const res = await apiFetch(Routes.inventory(interaction.user.id)); @@ -20,16 +35,20 @@ export default class BulkDismantleButton extends Button { const inventory: IInventoryItem[] = data?.inventory || []; const chunk = inventory.slice(pageOffset, pageOffset + ITEMS_PER_PAGE); - const eligible = chunk.filter(inv => { + const eligible = chunk.filter((inv) => { if (inv.isLocked) return false; - if (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) return false; + if (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) + return false; const def = ItemManager.get(inv.itemId); if (!def || def.type === 'Consumable') return false; return true; }); if (eligible.length === 0) { - await interaction.reply({ content: 'โŒ No eligible items to dismantle on this page.', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'โŒ No eligible items to dismantle on this page.', + flags: MessageFlags.Ephemeral + }); return; } @@ -60,8 +79,9 @@ export default class BulkDismantleButton extends Button { .setDescription('All selected items will be destroyed for Embers') .setStringSelectMenuComponent(selectMenu); - const infoText = new TextDisplayBuilder() - .setContent('-# ๐Ÿ”ฅ Items are permanently destroyed and converted to Embers. Enhanced items return bonus embers.'); + const infoText = new TextDisplayBuilder().setContent( + '-# ๐Ÿ”ฅ Items are permanently destroyed and converted to Embers. Enhanced items return bonus embers.' + ); const modal = new ModalBuilder() .setCustomId('bulk_dismantle_modal') @@ -71,7 +91,4 @@ export default class BulkDismantleButton extends Button { await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/BulkSellButton.ts b/src/components/buttons/BulkSellButton.ts index 1dc0b7d..42b1ef7 100644 --- a/src/components/buttons/BulkSellButton.ts +++ b/src/components/buttons/BulkSellButton.ts @@ -1,17 +1,32 @@ -import { ButtonInteraction, Client, LabelBuilder, MessageFlags, ModalBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, TextDisplayBuilder } from "discord.js"; -import Button from "../../structures/Button"; -import ItemManager from "../../managers/ItemManager"; -import { apiFetch } from "../../utilities/ApiClient"; -import Routes from "../../utilities/Routes"; -import type { IInventoryItem } from "../../interfaces/IInventoryJSON"; +import { + type ButtonInteraction, + type Client, + LabelBuilder, + MessageFlags, + ModalBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + TextDisplayBuilder +} from 'discord.js'; +import Button from '../../structures/Button'; +import * as ItemManager from '../../managers/ItemManager'; +import { apiFetch } from '../../utilities/ApiClient'; +import * as Routes from '../../utilities/Routes'; +import type { IInventoryItem } from '../../interfaces/IInventoryJSON'; const ITEMS_PER_PAGE = 15; export default class BulkSellButton extends Button { - constructor() { super('bulk_sell'); } + constructor() { + super({ customId: 'bulk_sell', cooldown: 3, isAuthorOnly: true }); + } // customId format: bulk_sell: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const pageOffset = parseInt(args?.[0] ?? '0', 10); // Fetch inventory fresh from API @@ -22,16 +37,20 @@ export default class BulkSellButton extends Button { // Get the page chunk and filter eligible items const chunk = inventory.slice(pageOffset, pageOffset + ITEMS_PER_PAGE); - const eligible = chunk.filter(inv => { + const eligible = chunk.filter((inv) => { if (inv.isLocked) return false; - if (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) return false; + if (inv.enhanceLevel > 0 || inv.statOverrides || inv.affixOverrides) + return false; const def = ItemManager.get(inv.itemId); if (!def || def.type === 'Consumable') return false; return true; }); if (eligible.length === 0) { - await interaction.reply({ content: 'โŒ No eligible items to sell on this page.', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'โŒ No eligible items to sell on this page.', + flags: MessageFlags.Ephemeral + }); return; } @@ -44,8 +63,10 @@ export default class BulkSellButton extends Button { options.push( new StringSelectMenuOptionBuilder() .setLabel(`${def.name} (x${inv.quantity})`) - .setDescription(`${def.rarity} ${def.type} โ€ข Sells for ${totalValue.toLocaleString()}g`) - .setValue(`${inv.itemId}-${inv.quantity}`) // Short โ€” no _id needed for bulk sell + .setDescription( + `${def.rarity} ${def.type} โ€ข Sells for ${totalValue.toLocaleString()}g` + ) + .setValue(`${inv.itemId}-${inv.quantity}`) // Short โ€” no _id needed for bulk sell ); } @@ -60,11 +81,14 @@ export default class BulkSellButton extends Button { const selectLabel = new LabelBuilder() .setLabel('Select items to sell') - .setDescription('All selected items will be sold for gold. Modified items are excluded.') + .setDescription( + 'All selected items will be sold for gold. Modified items are excluded.' + ) .setStringSelectMenuComponent(selectMenu); - const infoText = new TextDisplayBuilder() - .setContent('-# Locked, consumable, and modified (enhanced/reforged) items are excluded.'); + const infoText = new TextDisplayBuilder().setContent( + '-# Locked, consumable, and modified (enhanced/reforged) items are excluded.' + ); const modal = new ModalBuilder() .setCustomId('bulk_sell_modal') @@ -74,7 +98,4 @@ export default class BulkSellButton extends Button { await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/ChestBuyButton.ts b/src/components/buttons/ChestBuyButton.ts index 8884bce..b2b2e59 100644 --- a/src/components/buttons/ChestBuyButton.ts +++ b/src/components/buttons/ChestBuyButton.ts @@ -1,46 +1,68 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class ChestBuyButton extends Button { constructor() { - super('chest_buy'); + super({ customId: 'chest_buy', cooldown: 3, isAuthorOnly: true }); } // customId format: chest_buy: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const tier = args?.[0]; if (!tier) { - await interaction.editReply({ content: 'Error parsing chest tier!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing chest tier!', + files: [], + components: [], + embeds: [] + }); return; } try { const res = await apiFetch(Routes.chests(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, action: 'buy', tier }), + body: JSON.stringify({ + discordId: interaction.user.id, + action: 'buy', + tier + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to buy chest'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to buy chest'), + files: [], + components: [], + embeds: [] + }); return; } await interaction.editReply({ content: `๐Ÿ›’ **Purchased a ${tier} Chest!**\n๐Ÿช™ Cost: **${body.goldCost?.toLocaleString() ?? '???'}** gold\n๐Ÿ’ฐ Balance: **${body.newBalance?.toLocaleString() ?? '???'}** gold\n\nRun \`/chests\` to view your vault.`, - files: [], components: [], embeds: [], + files: [], + components: [], + embeds: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/ChestOpenButton.ts b/src/components/buttons/ChestOpenButton.ts index ec90506..0fd98c9 100644 --- a/src/components/buttons/ChestOpenButton.ts +++ b/src/components/buttons/ChestOpenButton.ts @@ -1,47 +1,68 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; const RARITY_EMOJIS: Record = { - Common: 'โฌœ', Uncommon: '๐ŸŸฉ', Rare: '๐ŸŸฆ', Elite: '๐ŸŸง', - Epic: '๐ŸŸช', Legendary: '๐ŸŸก', Divine: '๐Ÿ’Ž', Exotic: '๐Ÿ’œ', + Common: 'โฌœ', + Uncommon: '๐ŸŸฉ', + Rare: '๐ŸŸฆ', + Elite: '๐ŸŸง', + Epic: '๐ŸŸช', + Legendary: '๐ŸŸก', + Divine: '๐Ÿ’Ž', + Exotic: '๐Ÿ’œ' }; export default class ChestOpenButton extends Button { constructor() { - super('chest_open'); + super({ customId: 'chest_open', cooldown: 3, isAuthorOnly: true }); } // customId format: chest_open: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const chestId = args?.[0]; if (!chestId) { - await interaction.editReply({ content: 'Error parsing chest data!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing chest data!', + files: [], + components: [], + embeds: [] + }); return; } try { const res = await apiFetch(Routes.chests(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, action: 'open', chestId }), + body: JSON.stringify({ + discordId: interaction.user.id, + action: 'open', + chestId + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to open chest'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to open chest'), + files: [], + components: [], + embeds: [] + }); return; } const loot = body.loot; - const lines = [ - `๐ŸŽ‰ **Chest Opened!**`, - ``, - ]; + const lines = [`๐ŸŽ‰ **Chest Opened!**`, ``]; if (loot.isPity) { lines.push(`โœจ **PITY BONUS โ€” Guaranteed Divine item!**`); @@ -51,20 +72,31 @@ export default class ChestOpenButton extends Button { // Items for (const item of loot.items) { const emoji = RARITY_EMOJIS[item.rarity] || '๐Ÿ“ฆ'; - lines.push(`${emoji} **${item.name}** โ€” ${item.rarity} ${item.type} (Lvl ${item.level})`); + lines.push( + `${emoji} **${item.name}** โ€” ${item.rarity} ${item.type} (Lvl ${item.level})` + ); } // Gold & Embers lines.push(``); - if (loot.gold > 0) lines.push(`๐Ÿช™ **+${loot.gold.toLocaleString()}** Gold`); - if (loot.embers > 0) lines.push(`๐Ÿ”ฅ **+${loot.embers.toLocaleString()}** Embers`); + if (loot.gold > 0) + lines.push(`๐Ÿช™ **+${loot.gold.toLocaleString()}** Gold`); + if (loot.embers > 0) + lines.push(`๐Ÿ”ฅ **+${loot.embers.toLocaleString()}** Embers`); - await interaction.editReply({ content: lines.join('\n'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: lines.join('\n'), + files: [], + components: [], + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/ChestStartButton.ts b/src/components/buttons/ChestStartButton.ts index befc8a9..84bc2a1 100644 --- a/src/components/buttons/ChestStartButton.ts +++ b/src/components/buttons/ChestStartButton.ts @@ -1,46 +1,67 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class ChestStartButton extends Button { constructor() { - super('chest_start'); + super({ customId: 'chest_start', cooldown: 2, isAuthorOnly: true }); } // customId format: chest_start: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const chestId = args?.[0]; if (!chestId) { - await interaction.editReply({ content: 'Error parsing chest data!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing chest data!', + files: [], + components: [], + embeds: [] + }); return; } try { const res = await apiFetch(Routes.chests(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, action: 'start', chestId }), + body: JSON.stringify({ + discordId: interaction.user.id, + action: 'start', + chestId + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to start unlock'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to start unlock'), + files: [], + components: [], + embeds: [] + }); return; } await interaction.editReply({ content: `โณ **Chest unlocking!** It will be ready to open in **${body.unlockTime ?? 'a while'}**.\n\nRun \`/chests\` again later to open it.`, - files: [], components: [], + files: [], + components: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/CollectButton.ts b/src/components/buttons/CollectButton.ts index f7b4877..830027b 100644 --- a/src/components/buttons/CollectButton.ts +++ b/src/components/buttons/CollectButton.ts @@ -1,28 +1,41 @@ -import { ButtonInteraction, Client, ModalBuilder, TextInputStyle } from "discord.js"; -import Button from "../../structures/Button"; +import { + type ButtonInteraction, + type Client, + ModalBuilder, + TextInputStyle +} from 'discord.js'; +import Button from '../../structures/Button'; export default class CollectButton extends Button { constructor() { - super('collect'); + super({ customId: 'collect', cooldown: 2, isAuthorOnly: true }); } // customId format: collect:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const docId = args?.[0]; const maxQty = args?.[1] ?? '1'; const modal = new ModalBuilder() .setCustomId(`collect:${docId}`) .setTitle('โš ๏ธ Collect Item (Permanent)') - .addLabelComponents( - (label) => - label.setLabel('Amount').setDescription(`โš ๏ธ This is PERMANENT and cannot be undone. Items are removed from inventory and added to your Collection Book. (Max: ${maxQty})`) - .setTextInputComponent((ti) => ti.setCustomId('ti1').setRequired(true).setStyle(TextInputStyle.Short).setPlaceholder(maxQty)) + .addLabelComponents((label) => label + .setLabel('Amount') + .setDescription( + `โš ๏ธ This is PERMANENT and cannot be undone. Items are removed from inventory and added to your Collection Book. (Max: ${maxQty})` + ) + .setTextInputComponent((ti) => ti + .setCustomId('ti1') + .setRequired(true) + .setStyle(TextInputStyle.Short) + .setPlaceholder(maxQty) + ) ); await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/ConsumeButton.ts b/src/components/buttons/ConsumeButton.ts index dfedf63..c5b2a2b 100644 --- a/src/components/buttons/ConsumeButton.ts +++ b/src/components/buttons/ConsumeButton.ts @@ -1,28 +1,38 @@ -import { ButtonInteraction, Client, ModalBuilder, TextInputStyle } from "discord.js"; -import Button from "../../structures/Button"; +import { + type ButtonInteraction, + type Client, + ModalBuilder, + TextInputStyle +} from 'discord.js'; +import Button from '../../structures/Button'; export default class ConsumeButton extends Button { constructor() { - super('consume'); + super({ customId: 'consume', cooldown: 2, isAuthorOnly: true }); } // customId format: consume:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const docId = args?.[0]; const maxQty = args?.[1] ?? '1'; const modal = new ModalBuilder() .setTitle('Consume Item') .setCustomId(`consume:${docId}`) - .addLabelComponents( - (label) => - label.setLabel('Amount').setDescription(`Enter amount to consume (Max: ${maxQty})`) - .setTextInputComponent((ti) => ti.setCustomId('ti1').setRequired(true).setStyle(TextInputStyle.Short)) + .addLabelComponents((label) => label + .setLabel('Amount') + .setDescription(`Enter amount to consume (Max: ${maxQty})`) + .setTextInputComponent((ti) => ti + .setCustomId('ti1') + .setRequired(true) + .setStyle(TextInputStyle.Short) + ) ); await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/DismantleButton.ts b/src/components/buttons/DismantleButton.ts index cca69bf..f1e98be 100644 --- a/src/components/buttons/DismantleButton.ts +++ b/src/components/buttons/DismantleButton.ts @@ -1,16 +1,20 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class DismantleButton extends Button { constructor() { - super('dismantle'); + super({ customId: 'dismantle', cooldown: 3, isAuthorOnly: true }); } // customId format: dismantle::: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; @@ -18,7 +22,12 @@ export default class DismantleButton extends Button { const maxQty = parseInt(args?.[2] ?? '1', 10); if (!docId || isNaN(itemId)) { - await interaction.editReply({ content: 'Error parsing item data!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing item data!', + files: [], + components: [], + embeds: [] + }); return; } @@ -32,14 +41,19 @@ export default class DismantleButton extends Button { discordId: interaction.user.id, itemId, inventoryId: docId, - amount, - }), + amount + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Dismantle failed'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Dismantle failed'), + files: [], + components: [], + embeds: [] + }); return; } @@ -48,15 +62,19 @@ export default class DismantleButton extends Button { `๐Ÿ”ฅ **${body.message}**`, ``, `๐Ÿ”ฅ Embers gained: **+${body.embersGained?.toLocaleString() ?? '???'}**`, - `๐Ÿ”ฅ Total embers: **${body.newEmbers?.toLocaleString() ?? '???'}**`, + `๐Ÿ”ฅ Total embers: **${body.newEmbers?.toLocaleString() ?? '???'}**` ].join('\n'), - files: [], components: [], embeds: [] + files: [], + components: [], + embeds: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/EmbedAttackButton.ts b/src/components/buttons/EmbedAttackButton.ts index e62486f..46a3b99 100644 --- a/src/components/buttons/EmbedAttackButton.ts +++ b/src/components/buttons/EmbedAttackButton.ts @@ -1,15 +1,20 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { ICombatJSON } from "../../interfaces/ICombatJSON"; -import { apiFetch } from "../../utilities/ApiClient"; -import { buildCombatResponse } from "../../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { type ICombatJSON } from '../../interfaces/ICombatJSON'; +import { apiFetch } from '../../utilities/ApiClient'; +import { buildCombatResponse } from '../../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class EmbedAttackButton extends Button { - constructor() { super('embedAttack'); } + constructor() { + super({ customId: 'embedAttack', cooldown: 1.8, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferUpdate(); const res = await apiFetch(Routes.combat(), { @@ -22,7 +27,7 @@ export default class EmbedAttackButton extends Button { return; } - const data = await res.json() as ICombatJSON; + const data = (await res.json()) as ICombatJSON; if (data.error) { await interaction.editReply({ content: formatError(data.error) }); @@ -32,7 +37,4 @@ export default class EmbedAttackButton extends Button { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 1.8; } -} \ No newline at end of file +} diff --git a/src/components/buttons/EmbedFleeButton.ts b/src/components/buttons/EmbedFleeButton.ts index 43a4ba2..ab6fc13 100644 --- a/src/components/buttons/EmbedFleeButton.ts +++ b/src/components/buttons/EmbedFleeButton.ts @@ -1,15 +1,20 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { ICombatJSON } from "../../interfaces/ICombatJSON"; -import { apiFetch } from "../../utilities/ApiClient"; -import { buildCombatResponse } from "../../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { type ICombatJSON } from '../../interfaces/ICombatJSON'; +import { apiFetch } from '../../utilities/ApiClient'; +import { buildCombatResponse } from '../../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class EmbedFleeButton extends Button { - constructor() { super('embedFlee'); } + constructor() { + super({ customId: 'embedFlee', cooldown: 1.8, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferUpdate(); const res = await apiFetch(Routes.combat(), { @@ -22,7 +27,7 @@ export default class EmbedFleeButton extends Button { return; } - const data = await res.json() as ICombatJSON; + const data = (await res.json()) as ICombatJSON; if (data.error) { await interaction.editReply({ content: formatError(data.error) }); @@ -32,7 +37,4 @@ export default class EmbedFleeButton extends Button { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 1.8; } -} \ No newline at end of file +} diff --git a/src/components/buttons/EnhanceButton.ts b/src/components/buttons/EnhanceButton.ts index 2d4271e..36b33f6 100644 --- a/src/components/buttons/EnhanceButton.ts +++ b/src/components/buttons/EnhanceButton.ts @@ -1,36 +1,54 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class EnhanceButton extends Button { constructor() { - super('enhance'); + super({ customId: 'enhance', cooldown: 3, isAuthorOnly: true }); } // customId format: enhance:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; const itemId = parseInt(args?.[1] ?? '-1', 10); if (!docId || isNaN(itemId)) { - await interaction.editReply({ content: 'Error parsing item data!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing item data!', + files: [], + components: [], + embeds: [] + }); return; } try { const res = await apiFetch(Routes.enhance(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, itemId, inventoryId: docId }), + body: JSON.stringify({ + discordId: interaction.user.id, + itemId, + inventoryId: docId + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Enhancement failed'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Enhancement failed'), + files: [], + components: [], + embeds: [] + }); return; } @@ -38,7 +56,7 @@ export default class EnhanceButton extends Button { const lines = [ `โฌ†๏ธ **Enhancement ${result.succeeded ? 'Succeeded' : 'Failed'}!**`, ``, - `๐Ÿ“ฆ **${result.itemName}** โ†’ +${result.newLevel}`, + `๐Ÿ“ฆ **${result.itemName}** โ†’ +${result.newLevel}` ]; if (result.succeeded) { @@ -50,15 +68,27 @@ export default class EnhanceButton extends Button { } } - lines.push(``, `๐Ÿช™ Gold spent: **${result.goldCost?.toLocaleString() ?? '???'}**`); - lines.push(`๐Ÿ”ฅ Embers spent: **${result.emberCost?.toLocaleString() ?? '???'}**`); + lines.push( + ``, + `๐Ÿช™ Gold spent: **${result.goldCost?.toLocaleString() ?? '???'}**` + ); + lines.push( + `๐Ÿ”ฅ Embers spent: **${result.emberCost?.toLocaleString() ?? '???'}**` + ); - await interaction.editReply({ content: lines.join('\n'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: lines.join('\n'), + files: [], + components: [], + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/EquipButton.ts b/src/components/buttons/EquipButton.ts index 18d296b..8b2c3e2 100644 --- a/src/components/buttons/EquipButton.ts +++ b/src/components/buttons/EquipButton.ts @@ -1,45 +1,70 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class EquipButton extends Button { constructor() { - super('equip'); + super({ customId: 'equip', cooldown: 3, isAuthorOnly: true }); } // customId format: equip:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; const itemId = parseInt(args?.[1] ?? '-1', 10); if (!docId || isNaN(itemId)) { - await interaction.editReply({ files: [], components: [], content: 'Error parsing item data!', embeds: [] }); + await interaction.editReply({ + files: [], + components: [], + content: 'Error parsing item data!', + embeds: [] + }); return; } try { const res = await apiFetch(Routes.equip(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, itemId, inventoryId: docId }), + body: JSON.stringify({ + discordId: interaction.user.id, + itemId, + inventoryId: docId + }) }); const { success, error, message } = await res.json(); if (!res.ok || !success) { - await interaction.editReply({ files: [], components: [], content: formatError(error ?? 'Equip failed'), embeds: [] }); + await interaction.editReply({ + files: [], + components: [], + content: formatError(error ?? 'Equip failed'), + embeds: [] + }); return; } - await interaction.editReply({ files: [], components: [], content: message ?? 'Item equipped!', embeds: [] }); + await interaction.editReply({ + files: [], + components: [], + content: message ?? 'Item equipped!', + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ files: [], components: [], content: formatError(err.message, err.code), embeds: [] }); + await interaction.editReply({ + files: [], + components: [], + content: formatError(err.message, err.code), + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/ExploreAgainButton.ts b/src/components/buttons/ExploreAgainButton.ts index 3470b35..3cbd0ef 100644 --- a/src/components/buttons/ExploreAgainButton.ts +++ b/src/components/buttons/ExploreAgainButton.ts @@ -1,44 +1,60 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { buildCombatResponse } from "../../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; -import { IStepJSON } from "../../interfaces/IStepJSON"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { buildCombatResponse } from '../../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; +import { type IStepJSON } from '../../interfaces/IStepJSON'; export default class ExploreAgainButton extends Button { constructor() { - super('explore_again'); + super({ customId: 'explore_again', cooldown: 7, isAuthorOnly: true }); } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); try { const res = await apiFetch(Routes.explore(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id }), + body: JSON.stringify({ discordId: interaction.user.id }) }); - const data = await res.json() as IStepJSON; + const data = (await res.json()) as IStepJSON; if (res.status === 429) { - await interaction.editReply({ content: formatCooldown('step', data.cooldownRemaining), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatCooldown('step', data.cooldownRemaining), + files: [], + components: [], + embeds: [] + }); return; } if (data.error) { - await interaction.editReply({ content: formatError(data.error), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(data.error), + files: [], + components: [], + embeds: [] + }); return; } const response = await buildCombatResponse(data); await interaction.editReply(response); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 7; } } diff --git a/src/components/buttons/ExploreButton.ts b/src/components/buttons/ExploreButton.ts index b779536..388723b 100644 --- a/src/components/buttons/ExploreButton.ts +++ b/src/components/buttons/ExploreButton.ts @@ -1,31 +1,40 @@ -import { ButtonInteraction, Client, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { IStepJSON } from "../../interfaces/IStepJSON"; -import { apiFetch } from "../../utilities/ApiClient"; -import { buildCombatResponse } from "../../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { type ButtonInteraction, type Client, MessageFlags } from 'discord.js'; +import Button from '../../structures/Button'; +import { type IStepJSON } from '../../interfaces/IStepJSON'; +import { apiFetch } from '../../utilities/ApiClient'; +import { buildCombatResponse } from '../../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class ExploreButton extends Button { - constructor() { super('explore'); } + constructor() { + super({ customId: 'explore', cooldown: 7, isAuthorOnly: false }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const res = await apiFetch(Routes.explore(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id }), + body: JSON.stringify({ discordId: interaction.user.id }) }); - const data = await res.json() as IStepJSON; + const data = (await res.json()) as IStepJSON; if (res.status === 429) { - await interaction.editReply({ content: formatCooldown('step', data.cooldownRemaining) }); + await interaction.editReply({ + content: formatCooldown('step', data.cooldownRemaining) + }); return; } if (res.status === 404) { - await interaction.editReply({ content: formatError('', 'PLAYER_NOT_FOUND') }); + await interaction.editReply({ + content: formatError('', 'PLAYER_NOT_FOUND') + }); return; } @@ -37,7 +46,4 @@ export default class ExploreButton extends Button { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isAuthorOnly(): boolean { return false; } - public cooldown(): number { return 7; } -} \ No newline at end of file +} diff --git a/src/components/buttons/FleeButton.ts b/src/components/buttons/FleeButton.ts index 33f8fc0..2e27a1c 100644 --- a/src/components/buttons/FleeButton.ts +++ b/src/components/buttons/FleeButton.ts @@ -1,15 +1,20 @@ -import { ButtonInteraction, Client, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { ICombatJSON } from "../../interfaces/ICombatJSON"; -import { apiFetch } from "../../utilities/ApiClient"; -import { buildCombatResponse } from "../../utilities/CombatResponseBuilder"; -import { formatError, formatCooldown } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { type ButtonInteraction, type Client, MessageFlags } from 'discord.js'; +import Button from '../../structures/Button'; +import { type ICombatJSON } from '../../interfaces/ICombatJSON'; +import { apiFetch } from '../../utilities/ApiClient'; +import { buildCombatResponse } from '../../utilities/CombatResponseBuilder'; +import { formatError, formatCooldown } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class FleeButton extends Button { - constructor() { super('flee'); } + constructor() { + super({ customId: 'flee', cooldown: 1.8, isAuthorOnly: false }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const res = await apiFetch(Routes.combat(), { @@ -22,7 +27,7 @@ export default class FleeButton extends Button { return; } - const data = await res.json() as ICombatJSON; + const data = (await res.json()) as ICombatJSON; if (data.error) { await interaction.editReply({ content: formatError(data.error) }); @@ -32,7 +37,4 @@ export default class FleeButton extends Button { const response = await buildCombatResponse(data); await interaction.editReply(response); } - - public isAuthorOnly(): boolean { return false; } - public cooldown(): number { return 1.8; } -} \ No newline at end of file +} diff --git a/src/components/buttons/GuideNavButton.ts b/src/components/buttons/GuideNavButton.ts index 6f15628..21286f9 100644 --- a/src/components/buttons/GuideNavButton.ts +++ b/src/components/buttons/GuideNavButton.ts @@ -1,38 +1,53 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; -const SECTIONS: Record = { - basics: { title: 'Getting Started', emoji: '๐Ÿ“–', content: 'Use `/guide basics` to see full content.' }, +const SECTIONS: Record< + string, + { title: string; emoji: string; content: string } +> = { + basics: { + title: 'Getting Started', + emoji: '๐Ÿ“–', + content: 'Use `/guide basics` to see full content.' + }, combat: { title: 'Combat & Enemies', emoji: 'โš”๏ธ', content: '' }, workshop: { title: 'Workshop', emoji: '๐Ÿ”จ', content: '' }, economy: { title: 'Economy & Gold Sinks', emoji: '๐Ÿช™', content: '' }, tasks: { title: 'Tasks & Chests', emoji: '๐Ÿ“‹', content: '' }, - zones: { title: 'Zones & Travel', emoji: '๐Ÿ—บ๏ธ', content: '' }, + zones: { title: 'Zones & Travel', emoji: '๐Ÿ—บ๏ธ', content: '' } }; // Import the full sections from GuideCommand would create circular dependency, // so we duplicate the content lookup here. In practice, you'd extract SECTIONS to a shared file. // For now, this button just re-invokes the guide display logic. -const SECTION_ORDER = ['basics', 'combat', 'workshop', 'economy', 'tasks', 'zones']; +const SECTION_ORDER = [ + 'basics', + 'combat', + 'workshop', + 'economy', + 'tasks', + 'zones' +]; export default class GuideNavButton extends Button { constructor() { - super('guide_nav'); + super({ customId: 'guide_nav', cooldown: 1, isAuthorOnly: false }); } // customId format: guide_nav: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const section = args?.[0] ?? 'basics'; // Re-trigger the guide command programmatically isn't possible with buttons, // so we tell the user to use the command await interaction.reply({ content: `๐Ÿ“– Use \`/guide ${section}\` to view the **${SECTIONS[section]?.title ?? section}** section.`, - ephemeral: true, + ephemeral: true }); - } - - public isAuthorOnly(): boolean { return false; } // Anyone can navigate the guide - public cooldown(): number { return 1; } + } // Anyone can navigate the guide } diff --git a/src/components/buttons/LockButton.ts b/src/components/buttons/LockButton.ts index e8ba871..5037c3a 100644 --- a/src/components/buttons/LockButton.ts +++ b/src/components/buttons/LockButton.ts @@ -1,23 +1,32 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class LockButton extends Button { constructor() { - super('lock'); + super({ customId: 'lock', cooldown: 2, isAuthorOnly: true }); } // customId format: lock:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; const currentlyLocked = args?.[1] === '1'; if (!docId) { - await interaction.editReply({ content: 'Error parsing item data!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing item data!', + files: [], + components: [], + embeds: [] + }); return; } @@ -27,26 +36,35 @@ export default class LockButton extends Button { body: JSON.stringify({ discordId: interaction.user.id, inventoryId: docId, - isLocked: !currentlyLocked, // Toggle - }), + isLocked: !currentlyLocked // Toggle + }) }); const { success, isLocked, error } = await res.json(); if (!res.ok || !success) { - await interaction.editReply({ content: formatError(error ?? 'Lock failed'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(error ?? 'Lock failed'), + files: [], + components: [], + embeds: [] + }); return; } await interaction.editReply({ content: isLocked ? '๐Ÿ”’ Item locked!' : '๐Ÿ”“ Item unlocked!', - files: [], components: [], embeds: [], + files: [], + components: [], + embeds: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/MarketBuyButton.ts b/src/components/buttons/MarketBuyButton.ts index c975550..f8d6fe5 100644 --- a/src/components/buttons/MarketBuyButton.ts +++ b/src/components/buttons/MarketBuyButton.ts @@ -1,13 +1,19 @@ -import { ButtonInteraction, Client, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { type ButtonInteraction, type Client, MessageFlags } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class MarketBuyButton extends Button { - constructor() { super('mkt_buy'); } + constructor() { + super({ customId: 'mkt_buy', cooldown: 3, isAuthorOnly: false }); + } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const listingId = args?.[0]; @@ -19,23 +25,30 @@ export default class MarketBuyButton extends Button { try { const res = await apiFetch(Routes.marketBuy(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, listingId, quantity: 1 }), + body: JSON.stringify({ + discordId: interaction.user.id, + listingId, + quantity: 1 + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Purchase failed.') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Purchase failed.') + }); return; } const itemName = body.item?.name ?? 'Unknown Item'; - await interaction.editReply({ content: `๐Ÿช™ **Purchase complete!** You bought **${itemName}** from the Global Market.` }); + await interaction.editReply({ + content: `๐Ÿช™ **Purchase complete!** You bought **${itemName}** from the Global Market.` + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return false; } - public cooldown(): number { return 3; } -} \ No newline at end of file +} diff --git a/src/components/buttons/MarketCancelButton.ts b/src/components/buttons/MarketCancelButton.ts index 27f79d7..419fcad 100644 --- a/src/components/buttons/MarketCancelButton.ts +++ b/src/components/buttons/MarketCancelButton.ts @@ -1,13 +1,19 @@ -import { ButtonInteraction, Client, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { type ButtonInteraction, type Client, MessageFlags } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class MarketCancelButton extends Button { - constructor() { super('mkt_cancel'); } + constructor() { + super({ customId: 'mkt_cancel', cooldown: 3, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const listingId = args?.[0]; @@ -19,22 +25,26 @@ export default class MarketCancelButton extends Button { try { const res = await apiFetch(Routes.marketCancel(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, listingId }), + body: JSON.stringify({ discordId: interaction.user.id, listingId }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to cancel listing.') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to cancel listing.') + }); return; } - await interaction.editReply({ content: 'โœ… **Listing cancelled.** Your items have been returned to your inventory.' }); + await interaction.editReply({ + content: + 'โœ… **Listing cancelled.** Your items have been returned to your inventory.' + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } -} \ No newline at end of file +} diff --git a/src/components/buttons/MarketNextButton.ts b/src/components/buttons/MarketNextButton.ts index e72a2a8..7af5941 100644 --- a/src/components/buttons/MarketNextButton.ts +++ b/src/components/buttons/MarketNextButton.ts @@ -1,15 +1,18 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { handleMarketPage } from "./MarketPrevButton"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { handleMarketPage } from './MarketPrevButton'; export default class MarketNextButton extends Button { - constructor() { super('mkt_next'); } + constructor() { + super({ customId: 'mkt_next', cooldown: 2, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); await handleMarketPage(interaction, args, 1); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } -} \ No newline at end of file +} diff --git a/src/components/buttons/MarketPrevButton.ts b/src/components/buttons/MarketPrevButton.ts index 353f7fd..0b39a0e 100644 --- a/src/components/buttons/MarketPrevButton.ts +++ b/src/components/buttons/MarketPrevButton.ts @@ -1,27 +1,46 @@ -import { AttachmentBuilder, ButtonInteraction, Client, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; -import MarketImageBuilder, { type MarketListing, type MarketPageConfig } from "../../utilities/MarketImageBuilder"; +import { + AttachmentBuilder, + type ButtonInteraction, + type Client, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle +} from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; +import * as MarketImageBuilder from '../../utilities/MarketImageBuilder'; +import { + type MarketListing, + type MarketPageConfig +} from '../../utilities/MarketImageBuilder'; export default class MarketPrevButton extends Button { - constructor() { super('mkt_prev'); } + constructor() { + super({ customId: 'mkt_prev', cooldown: 2, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); await handleMarketPage(interaction, args, -1); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } /** * Shared pagination handler for prev/next. * CustomId format: mkt_prev:currentPage:search|rarity|type|sort:mode */ -export async function handleMarketPage(interaction: ButtonInteraction, args: string[] | null | undefined, direction: number): Promise { +export async function handleMarketPage( + interaction: ButtonInteraction, + args: string[] | null | undefined, + direction: number +): Promise { if (!args || args.length < 3) return; const currentPage = parseInt(args[0], 10); @@ -42,7 +61,7 @@ export async function handleMarketPage(interaction: ButtonInteraction, args: str search: search || undefined, rarity: rarity || 'All', type: type || 'All', - sort: sort || 'newest', + sort: sort || 'newest' }); } @@ -50,16 +69,27 @@ export async function handleMarketPage(interaction: ButtonInteraction, args: str const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load market') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load market') + }); return; } const listings: MarketListing[] = body.data; - const config: MarketPageConfig = { page: body.pagination.page, totalPages: body.pagination.totalPages, totalItems: body.pagination.totalItems, mode }; + const config: MarketPageConfig = { + page: body.pagination.page, + totalPages: body.pagination.totalPages, + totalItems: body.pagination.totalItems, + mode + }; const imageBuffer = await MarketImageBuilder.build(listings, config); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'market.png' }); - const embed = new EmbedBuilder().setColor(mode === 'my_listings' ? 0x3b82f6 : 0x10b981).setImage('attachment://market.png'); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'market.png' + }); + const embed = new EmbedBuilder() + .setColor(mode === 'my_listings' ? 0x3b82f6 : 0x10b981) + .setImage('attachment://market.png'); // Rebuild action + pagination buttons const rows: ActionRowBuilder[] = []; @@ -67,13 +97,23 @@ export async function handleMarketPage(interaction: ButtonInteraction, args: str if (listings.length > 0) { const isBrowse = mode === 'browse'; // Up to 2 rows of 4 action buttons - for (let rowStart = 0; rowStart < listings.length && rows.length < 2; rowStart += 4) { + for ( + let rowStart = 0; + rowStart < listings.length && rows.length < 2; + rowStart += 4 + ) { const row = new ActionRowBuilder(); - for (let j = rowStart; j < Math.min(rowStart + 4, listings.length); j++) { + for ( + let j = rowStart; + j < Math.min(rowStart + 4, listings.length); + j++ + ) { const listing = listings[j]; row.addComponents( new ButtonBuilder() - .setCustomId(`${isBrowse ? 'mkt_buy' : 'mkt_cancel'}:${listing.listingId}`) + .setCustomId( + `${isBrowse ? 'mkt_buy' : 'mkt_cancel'}:${listing.listingId}` + ) .setLabel(`${isBrowse ? '๐Ÿ›’' : 'โŒ'} ${j + 1}`) .setStyle(isBrowse ? ButtonStyle.Success : ButtonStyle.Danger) ); @@ -83,14 +123,30 @@ export async function handleMarketPage(interaction: ButtonInteraction, args: str } if (config.totalPages > 1) { - rows.push(new ActionRowBuilder().setComponents( - new ButtonBuilder().setCustomId(`mkt_prev:${config.page}:${filterKey}:${mode}`).setLabel('โ—€ Prev').setStyle(ButtonStyle.Secondary).setDisabled(config.page <= 1), - new ButtonBuilder().setCustomId(`mkt_next:${config.page}:${filterKey}:${mode}`).setLabel('Next โ–ถ').setStyle(ButtonStyle.Secondary).setDisabled(config.page >= config.totalPages), - )); + rows.push( + new ActionRowBuilder().setComponents( + new ButtonBuilder() + .setCustomId(`mkt_prev:${config.page}:${filterKey}:${mode}`) + .setLabel('โ—€ Prev') + .setStyle(ButtonStyle.Secondary) + .setDisabled(config.page <= 1), + new ButtonBuilder() + .setCustomId(`mkt_next:${config.page}:${filterKey}:${mode}`) + .setLabel('Next โ–ถ') + .setStyle(ButtonStyle.Secondary) + .setDisabled(config.page >= config.totalPages) + ) + ); } - await interaction.editReply({ embeds: [embed], files: [attachment], components: rows }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components: rows + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } -} \ No newline at end of file +} diff --git a/src/components/buttons/MarketRedirectButton.ts b/src/components/buttons/MarketRedirectButton.ts index 4662de2..1ea64be 100644 --- a/src/components/buttons/MarketRedirectButton.ts +++ b/src/components/buttons/MarketRedirectButton.ts @@ -1,21 +1,22 @@ -import { ButtonInteraction, Client, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; +import { type ButtonInteraction, type Client, MessageFlags } from 'discord.js'; +import Button from '../../structures/Button'; export default class MarketRedirectButton extends Button { constructor() { - super('market_redirect'); + super({ customId: 'market_redirect', cooldown: 2, isAuthorOnly: true }); } // customId format: market_redirect: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const itemId = args?.[0] ?? 'unknown'; await interaction.reply({ content: `๐Ÿ“ข **Modified items cannot be vendor-sold.**\n\nUse \`/market sell item:${itemId} quantity:1 price:\` to list this item on the Global Market.\n\nAlternatively, you can **๐Ÿ”ฅ Dismantle** it for Embers.`, - flags: MessageFlags.Ephemeral, + flags: MessageFlags.Ephemeral }); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/MarketSellPageButton.ts b/src/components/buttons/MarketSellPageButton.ts index 10a64b9..66b0fa6 100644 --- a/src/components/buttons/MarketSellPageButton.ts +++ b/src/components/buttons/MarketSellPageButton.ts @@ -1,15 +1,19 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { formatError } from "../../utilities/ErrorMessages"; -import { buildSellPage } from "../../commands/MarketCommand"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { formatError } from '../../utilities/ErrorMessages'; +import { buildSellPage } from '../../commands/MarketCommand'; export default class MarketSellPageButton extends Button { constructor() { - super('mkt_sell_page'); + super({ customId: 'mkt_sell_page', cooldown: 2, isAuthorOnly: true }); } // customId format: mkt_sell_page: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const page = parseInt(args?.[0] ?? '0', 10); @@ -18,10 +22,10 @@ export default class MarketSellPageButton extends Button { const result = await buildSellPage(interaction.user.id, page); await interaction.editReply(result); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), components: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + components: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/ReforgeButton.ts b/src/components/buttons/ReforgeButton.ts index dc5f80e..243d8ee 100644 --- a/src/components/buttons/ReforgeButton.ts +++ b/src/components/buttons/ReforgeButton.ts @@ -1,22 +1,32 @@ import { - ButtonInteraction, Client, ActionRowBuilder, - StringSelectMenuBuilder, StringSelectMenuOptionBuilder, - MessageFlags, -} from "discord.js"; -import Button from "../../structures/Button"; + type ButtonInteraction, + type Client, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + MessageFlags +} from 'discord.js'; +import Button from '../../structures/Button'; export default class ReforgeButton extends Button { constructor() { - super('reforge'); + super({ customId: 'reforge', cooldown: 3, isAuthorOnly: true }); } // customId format: reforge:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const docId = args?.[0]; const itemId = args?.[1]; if (!docId || !itemId) { - await interaction.reply({ content: 'Error parsing item data!', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'Error parsing item data!', + flags: MessageFlags.Ephemeral + }); return; } @@ -38,18 +48,18 @@ export default class ReforgeButton extends Button { new StringSelectMenuOptionBuilder() .setLabel('Full Reforge') .setDescription('Reroll both stats and affixes (costs more)') - .setValue('full'), + .setValue('full') ); - const row = new ActionRowBuilder().setComponents(selectMenu); + const row = new ActionRowBuilder().setComponents( + selectMenu + ); await interaction.reply({ - content: '๐Ÿ”„ **Select Reforge Type**\nChoose what to reroll on this item:', + content: + '๐Ÿ”„ **Select Reforge Type**\nChoose what to reroll on this item:', components: [row], - ephemeral: true, + ephemeral: true }); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/RegisterAcceptButton.ts b/src/components/buttons/RegisterAcceptButton.ts index 0c9854a..2fb07df 100644 --- a/src/components/buttons/RegisterAcceptButton.ts +++ b/src/components/buttons/RegisterAcceptButton.ts @@ -1,13 +1,24 @@ -import { ButtonInteraction, Client, Colors, ContainerBuilder, MessageFlags } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { + type ButtonInteraction, + type Client, + Colors, + ContainerBuilder, + MessageFlags +} from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class RegisterAcceptButton extends Button { - constructor() { super('register_accept'); } + constructor() { + super({ customId: 'register_accept', cooldown: 5, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferUpdate(); const discordId = interaction.user.id; @@ -17,14 +28,15 @@ export default class RegisterAcceptButton extends Button { try { const res = await apiFetch(Routes.registerPlayer(), { method: 'POST', - body: JSON.stringify({ discordId, username, avatar }), + body: JSON.stringify({ discordId, username, avatar }) }); if (res.status === 409) { await interaction.editReply({ - content: 'โœ… **You\'re already registered!** Use `/profile` to see your character.', + content: + "โœ… **You're already registered!** Use `/profile` to see your character.", embeds: [], - components: [], + components: [] }); return; } @@ -35,7 +47,7 @@ export default class RegisterAcceptButton extends Button { await interaction.editReply({ content: formatError(body.error ?? 'Registration failed.'), embeds: [], - components: [], + components: [] }); return; } @@ -43,40 +55,36 @@ export default class RegisterAcceptButton extends Button { const container = new ContainerBuilder().setAccentColor(Colors.Green); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('## โš”๏ธ Character Created!'), - (textDisplay) => - textDisplay.setContent(`Welcome to Dragon's Fall Online, **${username}**! Your adventure begins now.`), - (textDisplay) => - textDisplay.setContent( - '**Get started:**\n' + - '> `/explore` โ€” Venture into the world\n' + - '> `/profile` โ€” View your character\n' + - '> `/help` โ€” See all commands' - ), + (textDisplay) => textDisplay.setContent('## โš”๏ธ Character Created!'), + (textDisplay) => textDisplay.setContent( + `Welcome to Dragon's Fall Online, **${username}**! Your adventure begins now.` + ), + (textDisplay) => textDisplay.setContent( + '**Get started:**\n' + + '> `/explore` โ€” Venture into the world\n' + + '> `/profile` โ€” View your character\n' + + '> `/help` โ€” See all commands' + ) ); container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration โ€ข To request data deletion, contact the developer') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent( + '-# โš”๏ธ DFO Cross-Platform Integration โ€ข To request data deletion, contact the developer' + ) ); await interaction.editReply({ embeds: [], components: [container], - flags: MessageFlags.IsComponentsV2, + flags: MessageFlags.IsComponentsV2 }); } catch (err: any) { await interaction.editReply({ content: formatError(err.message, err.code), embeds: [], - components: [], + components: [] }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } -} \ No newline at end of file +} diff --git a/src/components/buttons/RegisterDeclineButton.ts b/src/components/buttons/RegisterDeclineButton.ts index 0dd8765..bb6e984 100644 --- a/src/components/buttons/RegisterDeclineButton.ts +++ b/src/components/buttons/RegisterDeclineButton.ts @@ -1,19 +1,22 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; export default class RegisterDeclineButton extends Button { - constructor() { super('register_decline'); } + constructor() { + super({ customId: 'register_decline', cooldown: 3, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client + ): Promise { await interaction.deferUpdate(); await interaction.editReply({ - content: '๐Ÿ‘‹ **No problem!** No data has been stored. You can run `/register` again anytime if you change your mind.', + content: + '๐Ÿ‘‹ **No problem!** No data has been stored. You can run `/register` again anytime if you change your mind.', embeds: [], - components: [], + components: [] }); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } -} \ No newline at end of file +} diff --git a/src/components/buttons/RestButton.ts b/src/components/buttons/RestButton.ts index 17a8429..7ab9df6 100644 --- a/src/components/buttons/RestButton.ts +++ b/src/components/buttons/RestButton.ts @@ -1,27 +1,36 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class RestButton extends Button { constructor() { - super('rest'); + super({ customId: 'rest', cooldown: 5, isAuthorOnly: true }); } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); try { const res = await apiFetch(Routes.rest(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id }), + body: JSON.stringify({ discordId: interaction.user.id }) }); const result = await res.json(); if (!res.ok || !result.success) { - await interaction.editReply({ content: formatError(result.error ?? 'Rest failed'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(result.error ?? 'Rest failed'), + files: [], + components: [], + embeds: [] + }); return; } @@ -29,15 +38,19 @@ export default class RestButton extends Button { content: [ `๐Ÿจ **Rested at the Inn**`, `โค๏ธ Restored **${result.healedAmount.toLocaleString()} HP** โ†’ ${result.newHp.toLocaleString()} / ${result.maxHp.toLocaleString()}`, - `๐Ÿช™ Cost: **${result.goldSpent.toLocaleString()}** Gold โ€ข ๐Ÿ’ฐ Balance: **${result.newBalance.toLocaleString()}** Gold`, + `๐Ÿช™ Cost: **${result.goldSpent.toLocaleString()}** Gold โ€ข ๐Ÿ’ฐ Balance: **${result.newBalance.toLocaleString()}** Gold` ].join('\n'), - files: [], components: [], embeds: [] + files: [], + components: [], + embeds: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/components/buttons/SellButton.ts b/src/components/buttons/SellButton.ts index 24ce3d8..18a7f94 100644 --- a/src/components/buttons/SellButton.ts +++ b/src/components/buttons/SellButton.ts @@ -1,28 +1,38 @@ -import { ButtonInteraction, Client, ModalBuilder, TextInputStyle } from "discord.js"; -import Button from "../../structures/Button"; +import { + type ButtonInteraction, + type Client, + ModalBuilder, + TextInputStyle +} from 'discord.js'; +import Button from '../../structures/Button'; export default class SellButton extends Button { constructor() { - super('sell'); + super({ customId: 'sell', cooldown: 2, isAuthorOnly: true }); } // customId format: sell:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const docId = args?.[0]; const maxQty = args?.[1] ?? '1'; const modal = new ModalBuilder() .setCustomId(`sell:${docId}`) .setTitle('Sell Item') - .addLabelComponents( - (label) => - label.setLabel('Amount').setDescription(`Enter amount to sell. (Max: ${maxQty})`) - .setTextInputComponent((ti) => ti.setCustomId('ti1').setRequired(true).setStyle(TextInputStyle.Short)) + .addLabelComponents((label) => label + .setLabel('Amount') + .setDescription(`Enter amount to sell. (Max: ${maxQty})`) + .setTextInputComponent((ti) => ti + .setCustomId('ti1') + .setRequired(true) + .setStyle(TextInputStyle.Short) + ) ); await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/buttons/SkillPointsButton.ts b/src/components/buttons/SkillPointsButton.ts index 84d9721..d5ca154 100644 --- a/src/components/buttons/SkillPointsButton.ts +++ b/src/components/buttons/SkillPointsButton.ts @@ -1,10 +1,24 @@ -import { ButtonInteraction, Client, LabelBuilder, ModalBuilder, TextDisplayBuilder, TextInputBuilder, TextInputStyle } from "discord.js"; -import Button from "../../structures/Button"; +import { + type ButtonInteraction, + type Client, + LabelBuilder, + ModalBuilder, + TextDisplayBuilder, + TextInputBuilder, + TextInputStyle +} from 'discord.js'; +import Button from '../../structures/Button'; export default class SkillPointsButton extends Button { - constructor() { super('skillpoints'); } + constructor() { + super({ customId: 'skillpoints', cooldown: 3, isAuthorOnly: true }); + } - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { const availablePoints = parseInt(args?.[0] ?? '0', 10); const atkInput = new TextInputBuilder() @@ -31,8 +45,9 @@ export default class SkillPointsButton extends Button { .setDescription('Increases your damage reduction per point') .setTextInputComponent(defInput); - const infoText = new TextDisplayBuilder() - .setContent(`-# You have **${availablePoints}** skill points available. This action is permanent.`); + const infoText = new TextDisplayBuilder().setContent( + `-# You have **${availablePoints}** skill points available. This action is permanent.` + ); const modal = new ModalBuilder() .setCustomId('skillpoints_modal') @@ -42,7 +57,4 @@ export default class SkillPointsButton extends Button { await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } -} \ No newline at end of file +} diff --git a/src/components/buttons/TaskClaimButton.ts b/src/components/buttons/TaskClaimButton.ts index 1c36bda..f44d37f 100644 --- a/src/components/buttons/TaskClaimButton.ts +++ b/src/components/buttons/TaskClaimButton.ts @@ -1,63 +1,92 @@ -import { ButtonInteraction, Client } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ButtonInteraction, Client } from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class TaskClaimButton extends Button { constructor() { - super('task_claim'); + super({ customId: 'task_claim', cooldown: 3, isAuthorOnly: true }); } // customId format: task_claim:: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const taskId = args?.[0]; const period = args?.[1] ?? 'daily'; if (!taskId) { - await interaction.editReply({ content: 'Error parsing task data!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing task data!', + files: [], + components: [], + embeds: [] + }); return; } try { const res = await apiFetch(Routes.tasks(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, action: 'claim', taskId, period }), + body: JSON.stringify({ + discordId: interaction.user.id, + action: 'claim', + taskId, + period + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to claim task'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to claim task'), + files: [], + components: [], + embeds: [] + }); return; } const reward = body.reward; - const lines = [ - `โœ… **Task Claimed!**`, - ``, - ]; + const lines = [`โœ… **Task Claimed!**`, ``]; if (reward) { - if (reward.gold > 0) lines.push(`๐Ÿช™ **+${reward.gold.toLocaleString()}** Gold`); - if (reward.xp > 0) lines.push(`โœจ **+${reward.xp.toLocaleString()}** XP`); - if (reward.embers > 0) lines.push(`๐Ÿ”ฅ **+${reward.embers.toLocaleString()}** Embers`); + if (reward.gold > 0) + lines.push(`๐Ÿช™ **+${reward.gold.toLocaleString()}** Gold`); + if (reward.xp > 0) + lines.push(`โœจ **+${reward.xp.toLocaleString()}** XP`); + if (reward.embers > 0) + lines.push(`๐Ÿ”ฅ **+${reward.embers.toLocaleString()}** Embers`); } if (body.levelsGained > 0) { - lines.push(``, `๐Ÿ†™ **Gained ${body.levelsGained} Level${body.levelsGained > 1 ? 's' : ''}!**`); + lines.push( + ``, + `๐Ÿ†™ **Gained ${body.levelsGained} Level${body.levelsGained > 1 ? 's' : ''}!**` + ); } lines.push(``, `Run \`/tasks ${period}\` to see remaining tasks.`); - await interaction.editReply({ content: lines.join('\n'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: lines.join('\n'), + files: [], + components: [], + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/buttons/TasksTabButton.ts b/src/components/buttons/TasksTabButton.ts index 796073e..81e7804 100644 --- a/src/components/buttons/TasksTabButton.ts +++ b/src/components/buttons/TasksTabButton.ts @@ -1,18 +1,30 @@ -import { ButtonInteraction, Client, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, AttachmentBuilder } from "discord.js"; -import Button from "../../structures/Button"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; -import ImageService from "../../utilities/ImageService"; -import type { ITaskJSON } from "../../interfaces/IGameJSON"; +import { + type ButtonInteraction, + type Client, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + AttachmentBuilder +} from 'discord.js'; +import Button from '../../structures/Button'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; +import * as ImageService from '../../utilities/ImageService'; +import type { ITaskJSON } from '../../interfaces/IGameJSON'; export default class TasksTabButton extends Button { constructor() { - super('tasks_tab'); + super({ customId: 'tasks_tab', cooldown: 3, isAuthorOnly: true }); } // customId format: tasks_tab: - public async execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const period = args?.[0] ?? 'daily'; @@ -23,7 +35,9 @@ export default class TasksTabButton extends Button { const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load tasks') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load tasks') + }); return; } @@ -34,23 +48,35 @@ export default class TasksTabButton extends Button { // Convert ISO reset string to ms remaining const resetIso = resets[period]; - const resetIn = resetIso ? Math.max(0, new Date(resetIso).getTime() - Date.now()) : 0; + const resetIn = resetIso + ? Math.max(0, new Date(resetIso).getTime() - Date.now()) + : 0; const imageBuffer = await ImageService.tasks(tasks, { period, resetIn, - playerEmbers, + playerEmbers }); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'tasks.png' }); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'tasks.png' + }); const embed = new EmbedBuilder() - .setColor(period === 'daily' ? 0x10b981 : period === 'weekly' ? 0x6366f1 : 0xc026d3) + .setColor( + period === 'daily' + ? 0x10b981 + : period === 'weekly' + ? 0x6366f1 + : 0xc026d3 + ) .setImage('attachment://tasks.png'); const components: ActionRowBuilder[] = []; // Claim buttons โ€” use correct field names (id, label, claimed) - const claimable = tasks.filter((t: ITaskJSON) => t.progress >= t.target && !t.claimed); + const claimable = tasks.filter( + (t: ITaskJSON) => t.progress >= t.target && !t.claimed + ); if (claimable.length > 0) { const claimRow = new ActionRowBuilder(); for (const task of claimable.slice(0, 5)) { @@ -67,18 +93,39 @@ export default class TasksTabButton extends Button { // Period switcher components.push( new ActionRowBuilder().setComponents( - new ButtonBuilder().setCustomId('tasks_tab:daily').setLabel('Daily').setStyle(period === 'daily' ? ButtonStyle.Primary : ButtonStyle.Secondary).setDisabled(period === 'daily'), - new ButtonBuilder().setCustomId('tasks_tab:weekly').setLabel('Weekly').setStyle(period === 'weekly' ? ButtonStyle.Primary : ButtonStyle.Secondary).setDisabled(period === 'weekly'), - new ButtonBuilder().setCustomId('tasks_tab:monthly').setLabel('Monthly').setStyle(period === 'monthly' ? ButtonStyle.Primary : ButtonStyle.Secondary).setDisabled(period === 'monthly'), + new ButtonBuilder() + .setCustomId('tasks_tab:daily') + .setLabel('Daily') + .setStyle( + period === 'daily' ? ButtonStyle.Primary : ButtonStyle.Secondary + ) + .setDisabled(period === 'daily'), + new ButtonBuilder() + .setCustomId('tasks_tab:weekly') + .setLabel('Weekly') + .setStyle( + period === 'weekly' ? ButtonStyle.Primary : ButtonStyle.Secondary + ) + .setDisabled(period === 'weekly'), + new ButtonBuilder() + .setCustomId('tasks_tab:monthly') + .setLabel('Monthly') + .setStyle( + period === 'monthly' ? ButtonStyle.Primary : ButtonStyle.Secondary + ) + .setDisabled(period === 'monthly') ) ); - await interaction.editReply({ embeds: [embed], files: [attachment], components: components }); + await interaction.editReply({ + embeds: [embed], + files: [attachment], + components + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/menus/InvSelectMenu.ts b/src/components/menus/InvSelectMenu.ts index 4a11b0b..4616a5e 100644 --- a/src/components/menus/InvSelectMenu.ts +++ b/src/components/menus/InvSelectMenu.ts @@ -1,26 +1,35 @@ -import { AnySelectMenuInteraction, Client } from "discord.js"; -import SelectMenu from "../../structures/SelectMenu"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; -import { buildItemView } from "../../utilities/ItemViewBuilder"; -import type { IInventoryItem } from "../../interfaces/IInventoryJSON"; -import type { IPlayerJSON } from "../../interfaces/IPlayerJSON"; +import type { AnySelectMenuInteraction, Client } from 'discord.js'; +import SelectMenu from '../../structures/SelectMenu'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; +import { buildItemView } from '../../utilities/ItemViewBuilder'; +import type { IInventoryItem } from '../../interfaces/IInventoryJSON'; +import type { IPlayerJSON } from '../../interfaces/IPlayerJSON'; export default class InvSelectMenu extends SelectMenu { constructor() { - super('inv_select'); + super({ customId: 'inv_select', cooldown: 3, isAuthorOnly: true }); } // customId format: inv_select: โ€” value is the _id of the selected item - public async execute(interaction: AnySelectMenuInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: AnySelectMenuInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = interaction.values[0]; // The MongoDB _id const discordId = interaction.user.id; if (!docId) { - await interaction.editReply({ content: 'No item selected!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'No item selected!', + files: [], + components: [], + embeds: [] + }); return; } @@ -30,18 +39,28 @@ export default class InvSelectMenu extends SelectMenu { const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to load inventory'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to load inventory'), + files: [], + components: [], + embeds: [] + }); return; } - const inventory: IInventoryItem[] = body.data.inventory || []; - const player: IPlayerJSON = body.data.player; + const inventory: IInventoryItem[] = body.builder.inventory || []; + const player: IPlayerJSON = body.builder.player; // Find the exact item by _id const item = inventory.find((inv: IInventoryItem) => inv._id === docId); if (!item) { - await interaction.editReply({ content: 'Item not found in your inventory!', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Item not found in your inventory!', + files: [], + components: [], + embeds: [] + }); return; } @@ -49,10 +68,12 @@ export default class InvSelectMenu extends SelectMenu { const viewer = await buildItemView(player, item); await interaction.editReply({ ...viewer, embeds: viewer.embeds }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/menus/MarketSellMenu.ts b/src/components/menus/MarketSellMenu.ts index e6ca3e6..c0e0bd2 100644 --- a/src/components/menus/MarketSellMenu.ts +++ b/src/components/menus/MarketSellMenu.ts @@ -1,14 +1,24 @@ -import { AnySelectMenuInteraction, Client, MessageFlags, ModalBuilder, TextInputStyle } from "discord.js"; -import SelectMenu from "../../structures/SelectMenu"; -import ItemManager from "../../managers/ItemManager"; +import { + type AnySelectMenuInteraction, + type Client, + MessageFlags, + ModalBuilder, + TextInputStyle +} from 'discord.js'; +import SelectMenu from '../../structures/SelectMenu'; +import * as ItemManager from '../../managers/ItemManager'; export default class MarketSellMenu extends SelectMenu { constructor() { - super('mkt_sell_select'); + super({ customId: 'mkt_sell_select', cooldown: 3, isAuthorOnly: true }); } // select value format: docId:itemId:maxQuantity - public async execute(interaction: AnySelectMenuInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: AnySelectMenuInteraction, + client: Client, + args?: string[] | null + ): Promise { const selected = interaction.values[0]; if (!selected) return; @@ -17,7 +27,10 @@ export default class MarketSellMenu extends SelectMenu { const maxQty = parseInt(maxQtyStr, 10); if (!docId || isNaN(itemId)) { - await interaction.reply({ content: 'Error parsing item data!', flags: MessageFlags.Ephemeral }); + await interaction.reply({ + content: 'Error parsing item data!', + flags: MessageFlags.Ephemeral + }); return; } @@ -29,30 +42,29 @@ export default class MarketSellMenu extends SelectMenu { const modal = new ModalBuilder() .setCustomId(`mkt_sell_modal:${docId}:${itemId}`) .setTitle(`๐Ÿช List: ${itemName.slice(0, 30)}`) - .addLabelComponents( - (label) => - label.setLabel('Quantity').setDescription(`How many to list (Max: ${maxQty})`) - .setTextInputComponent((ti) => - ti.setCustomId('quantity') - .setRequired(true) - .setStyle(TextInputStyle.Short) - .setPlaceholder(`1 - ${maxQty}`) - ) + .addLabelComponents((label) => label + .setLabel('Quantity') + .setDescription(`How many to list (Max: ${maxQty})`) + .setTextInputComponent((ti) => ti + .setCustomId('quantity') + .setRequired(true) + .setStyle(TextInputStyle.Short) + .setPlaceholder(`1 - ${maxQty}`) + ) ) - .addLabelComponents( - (label) => - label.setLabel('Price per unit (gold)').setDescription(`Suggested: ${baseValue.toLocaleString()}g (base value)`) - .setTextInputComponent((ti) => - ti.setCustomId('price') - .setRequired(true) - .setStyle(TextInputStyle.Short) - .setPlaceholder(`e.g. ${baseValue || 100}`) - ) + .addLabelComponents((label) => label + .setLabel('Price per unit (gold)') + .setDescription( + `Suggested: ${baseValue.toLocaleString()}g (base value)` + ) + .setTextInputComponent((ti) => ti + .setCustomId('price') + .setRequired(true) + .setStyle(TextInputStyle.Short) + .setPlaceholder(`e.g. ${baseValue || 100}`) + ) ); await interaction.showModal(modal); } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/menus/ReforgeSelectMenu.ts b/src/components/menus/ReforgeSelectMenu.ts index 1249c46..36839c2 100644 --- a/src/components/menus/ReforgeSelectMenu.ts +++ b/src/components/menus/ReforgeSelectMenu.ts @@ -1,16 +1,20 @@ -import { AnySelectMenuInteraction, Client } from "discord.js"; -import SelectMenu from "../../structures/SelectMenu"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { AnySelectMenuInteraction, Client } from 'discord.js'; +import SelectMenu from '../../structures/SelectMenu'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class ReforgeSelectMenu extends SelectMenu { constructor() { - super('reforge_select'); + super({ customId: 'reforge_select', cooldown: 3, isAuthorOnly: true }); } // customId format: reforge_select:: - public async execute(interaction: AnySelectMenuInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: AnySelectMenuInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; @@ -18,7 +22,11 @@ export default class ReforgeSelectMenu extends SelectMenu { const reforgeType = interaction.values[0]; // 'stats' | 'affixes' | 'full' if (!docId || isNaN(itemId) || !reforgeType) { - await interaction.editReply({ content: 'Error parsing reforge data!', components: [], embeds: [] }); + await interaction.editReply({ + content: 'Error parsing reforge data!', + components: [], + embeds: [] + }); return; } @@ -29,14 +37,18 @@ export default class ReforgeSelectMenu extends SelectMenu { discordId: interaction.user.id, itemId, inventoryId: docId, - reforgeType, - }), + reforgeType + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Reforge failed'), components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(body.error ?? 'Reforge failed'), + components: [], + embeds: [] + }); return; } @@ -45,12 +57,16 @@ export default class ReforgeSelectMenu extends SelectMenu { `๐Ÿ“ฆ **${body.itemName}**`, `๐Ÿช™ Cost: **${body.goldSpent?.toLocaleString() ?? '???'}** gold`, `๐Ÿ’ฐ Balance: **${body.newBalance?.toLocaleString() ?? '???'}** gold`, - ``, + `` ]; // Show stat comparison if stats were reforged - if (body.oldStats && body.newStats && (reforgeType === 'stats' || reforgeType === 'full')) { - const fmtStat = (label: string, old: number, now: number) => { + if ( + body.oldStats && + body.newStats && + (reforgeType === 'stats' || reforgeType === 'full') + ) { + const fmtStat = (label: string, old: number, now: number): string => { const diff = now - old; const arrow = diff > 0 ? '๐ŸŸข' : diff < 0 ? '๐Ÿ”ด' : 'โšช'; return `${arrow} ${label}: ${old} โ†’ **${now}** (${diff > 0 ? '+' : ''}${diff})`; @@ -62,23 +78,33 @@ export default class ReforgeSelectMenu extends SelectMenu { } // Show affix comparison if affixes were reforged - if (body.newAffixes && (reforgeType === 'affixes' || reforgeType === 'full')) { + if ( + body.newAffixes && + (reforgeType === 'affixes' || reforgeType === 'full') + ) { lines.push(``, `**New Affixes:**`); if (body.newAffixes.length === 0) { lines.push('None'); } else { for (const affix of body.newAffixes) { - lines.push(`โ€ข ${affix.type.replace(/_/g, ' ')} +${affix.value}${affix.type === 'THORNS' ? '' : '%'}`); + lines.push( + `โ€ข ${affix.type.replace(/_/g, ' ')} +${affix.value}${affix.type === 'THORNS' ? '' : '%'}` + ); } } } - await interaction.editReply({ content: lines.join('\n'), components: [], embeds: [] }); + await interaction.editReply({ + content: lines.join('\n'), + components: [], + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/menus/TravelSelectMenu.ts b/src/components/menus/TravelSelectMenu.ts index 2bc06f7..35a8ae3 100644 --- a/src/components/menus/TravelSelectMenu.ts +++ b/src/components/menus/TravelSelectMenu.ts @@ -1,14 +1,23 @@ -import { AnySelectMenuInteraction, Client, MessageFlags } from "discord.js"; -import SelectMenu from "../../structures/SelectMenu"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; -import { getZone } from "../../utilities/ZoneData"; +import { + type AnySelectMenuInteraction, + type Client, + MessageFlags +} from 'discord.js'; +import SelectMenu from '../../structures/SelectMenu'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; +import { getZone } from '../../utilities/ZoneData'; export default class TravelSelectMenu extends SelectMenu { - constructor() { super('travel_select'); } + constructor() { + super({ customId: 'travel_select', cooldown: 5, isAuthorOnly: true }); + } - public async execute(interaction: AnySelectMenuInteraction, client: Client): Promise { + public async execute( + interaction: AnySelectMenuInteraction, + client: Client + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const zoneId = parseInt(interaction.values[0], 10); @@ -23,13 +32,15 @@ export default class TravelSelectMenu extends SelectMenu { try { const res = await apiFetch(Routes.travel(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, zoneId }), + body: JSON.stringify({ discordId: interaction.user.id, zoneId }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Travel failed.') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Travel failed.') + }); return; } @@ -37,10 +48,9 @@ export default class TravelSelectMenu extends SelectMenu { content: `๐Ÿ—บ๏ธ **Traveled to ${zone?.name ?? body.zoneName}!**\n\n> *${zone?.description ?? 'A new zone awaits.'}*\n\nUse \`/explore\` to begin adventuring here.` }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } -} \ No newline at end of file +} diff --git a/src/components/menus/UnequipMenu.ts b/src/components/menus/UnequipMenu.ts index 7c091e2..1fb3ab2 100644 --- a/src/components/menus/UnequipMenu.ts +++ b/src/components/menus/UnequipMenu.ts @@ -1,18 +1,29 @@ -import { ActionRowBuilder, AnySelectMenuInteraction, AttachmentBuilder, Client, StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from "discord.js"; -import SelectMenu from "../../structures/SelectMenu"; -import Routes from "../../utilities/Routes"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import { IPlayerJSON } from "../../interfaces/IPlayerJSON"; -import ImageService from "../../utilities/ImageService"; -import { EquipmentSlot } from "../../interfaces/IItemJSON"; +import { + ActionRowBuilder, + type AnySelectMenuInteraction, + AttachmentBuilder, + type Client, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder +} from 'discord.js'; +import SelectMenu from '../../structures/SelectMenu'; +import * as Routes from '../../utilities/Routes'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import { type IPlayerJSON } from '../../interfaces/IPlayerJSON'; +import * as ImageService from '../../utilities/ImageService'; +import { type EquipmentSlot } from '../../interfaces/IItemJSON'; export default class UnequipMenu extends SelectMenu { constructor() { - super('unequip'); + super({ customId: 'unequip', cooldown: 2, isAuthorOnly: true }); } - public async execute(interaction: AnySelectMenuInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: AnySelectMenuInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const slot = interaction.values[0]; @@ -24,54 +35,80 @@ export default class UnequipMenu extends SelectMenu { body: JSON.stringify({ discordId, slot }) }); - const { success, error, player }: { success?: boolean, error?: string, player?: IPlayerJSON } = await res.json(); - - if (res.status === 400 || res.status === 401 || res.status === 404 || res.status === 500) { - await interaction.editReply({ content: formatError(error ?? `Unequip failed (Code: ${res.status})`), files: [], components: [], embeds: [] }); + const { + success, + error, + player + }: { success?: boolean; error?: string; player?: IPlayerJSON } = + await res.json(); + + if ( + res.status === 400 || + res.status === 401 || + res.status === 404 || + res.status === 500 + ) { + await interaction.editReply({ + content: formatError(error ?? `Unequip failed (Code: ${res.status})`), + files: [], + components: [], + embeds: [] + }); return; } if (success) { const profile = await ImageService.profile(player!, interaction.user); - const profileAttachment = new AttachmentBuilder(profile, { name: 'profile.png' }); + const profileAttachment = new AttachmentBuilder(profile, { + name: 'profile.png' + }); if (player!.id === interaction.user.id) { const options: StringSelectMenuOptionBuilder[] = []; const equipment = player!.equipment; - Object.entries(equipment).forEach(entry => { + Object.entries(equipment).forEach((entry) => { const slot = entry[0] as EquipmentSlot; const itemId = entry[1]; if (itemId) { - options.push(new StringSelectMenuOptionBuilder().setLabel(slot).setValue(slot)); + options.push( + new StringSelectMenuOptionBuilder().setLabel(slot).setValue(slot) + ); } }); - const menu = new StringSelectMenuBuilder().setCustomId('unequip') - .setOptions(options.length >= 1 ? options : [new StringSelectMenuOptionBuilder().setLabel('None').setValue('None')]) + const menu = new StringSelectMenuBuilder() + .setCustomId('unequip') + .setOptions( + options.length >= 1 + ? options + : [ + new StringSelectMenuOptionBuilder() + .setLabel('None') + .setValue('None') + ] + ) .setMaxValues(1) .setPlaceholder('Unequip Slot'); - extraMenu.push(new ActionRowBuilder().setComponents(menu)); + extraMenu.push( + new ActionRowBuilder().setComponents(menu) + ); } await interaction.editReply({ files: [profileAttachment], - components: extraMenu, + components: extraMenu }); } else { - await interaction.editReply({ content: 'Unknown error!', components: [], embeds: [], files: [] }); - return; + await interaction.editReply({ + content: 'Unknown error!', + components: [], + embeds: [], + files: [] + }); } } - - public isAuthorOnly(): boolean { - return true; - } - - public cooldown(): number { - return 2; - } } diff --git a/src/components/modals/BulkCollectModal.ts b/src/components/modals/BulkCollectModal.ts index 413d1cd..eaf3a9e 100644 --- a/src/components/modals/BulkCollectModal.ts +++ b/src/components/modals/BulkCollectModal.ts @@ -1,56 +1,86 @@ -import { ModalSubmitInteraction, Client, MessageFlags } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { + type ModalSubmitInteraction, + type Client, + MessageFlags +} from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class BulkCollectModal extends ModalSubmit { - constructor() { super('bulk_collect_modal'); } + constructor() { + super({ customId: 'bulk_collect_modal', cooldown: 5, isAuthorOnly: true }); + } - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const selectedValues = interaction.fields.getStringSelectValues('bulk_collect_select'); + const selectedValues = interaction.fields.getStringSelectValues( + 'bulk_collect_select' + ); if (!selectedValues || selectedValues.length === 0) { - await interaction.editReply({ content: 'โŒ No items were selected.', embeds: [] }); + await interaction.editReply({ + content: 'โŒ No items were selected.', + embeds: [] + }); return; } - const items = selectedValues.map(val => { - const parts = val.split('-'); - if (parts.length >= 3) { - return { inventoryId: parts[0], itemId: parseInt(parts[1], 10), amount: parseInt(parts[2], 10) }; - } - return { itemId: parseInt(parts[0], 10), amount: parseInt(parts[1], 10) }; - }).filter(i => !isNaN(i.itemId) && !isNaN(i.amount) && i.amount > 0); + const items = selectedValues + .map((val) => { + const parts = val.split('-'); + if (parts.length >= 3) { + return { + inventoryId: parts[0], + itemId: parseInt(parts[1], 10), + amount: parseInt(parts[2], 10) + }; + } + return { + itemId: parseInt(parts[0], 10), + amount: parseInt(parts[1], 10) + }; + }) + .filter((i) => !isNaN(i.itemId) && !isNaN(i.amount) && i.amount > 0); if (items.length === 0) { - await interaction.editReply({ content: 'โŒ Could not parse selected items.', embeds: [] }); + await interaction.editReply({ + content: 'โŒ Could not parse selected items.', + embeds: [] + }); return; } try { const res = await apiFetch(Routes.bulkCollect(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, items }), + body: JSON.stringify({ discordId: interaction.user.id, items }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Bulk collect failed.') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Bulk collect failed.') + }); return; } await interaction.editReply({ - content: `๐Ÿ“– **Bulk Collect Complete!** ${body.message}`, embeds: [] + content: `๐Ÿ“– **Bulk Collect Complete!** ${body.message}`, + embeds: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/components/modals/BulkDismantleModal.ts b/src/components/modals/BulkDismantleModal.ts index afc8828..3087c45 100644 --- a/src/components/modals/BulkDismantleModal.ts +++ b/src/components/modals/BulkDismantleModal.ts @@ -1,45 +1,74 @@ -import { ModalSubmitInteraction, Client, MessageFlags } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { + type ModalSubmitInteraction, + type Client, + MessageFlags +} from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class BulkDismantleModal extends ModalSubmit { - constructor() { super('bulk_dismantle_modal'); } + constructor() { + super({ + customId: 'bulk_dismantle_modal', + cooldown: 5, + isAuthorOnly: true + }); + } - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const selectedValues = interaction.fields.getStringSelectValues('bulk_dismantle_select'); + const selectedValues = interaction.fields.getStringSelectValues( + 'bulk_dismantle_select' + ); if (!selectedValues || selectedValues.length === 0) { await interaction.editReply({ content: 'โŒ No items were selected.' }); return; } - const items = selectedValues.map(val => { - const parts = val.split('-'); - if (parts.length >= 3) { - return { inventoryId: parts[0], itemId: parseInt(parts[1], 10), amount: parseInt(parts[2], 10) }; - } - return { itemId: parseInt(parts[0], 10), amount: parseInt(parts[1], 10) }; - }).filter(i => !isNaN(i.itemId) && !isNaN(i.amount) && i.amount > 0); + const items = selectedValues + .map((val) => { + const parts = val.split('-'); + if (parts.length >= 3) { + return { + inventoryId: parts[0], + itemId: parseInt(parts[1], 10), + amount: parseInt(parts[2], 10) + }; + } + return { + itemId: parseInt(parts[0], 10), + amount: parseInt(parts[1], 10) + }; + }) + .filter((i) => !isNaN(i.itemId) && !isNaN(i.amount) && i.amount > 0); if (items.length === 0) { - await interaction.editReply({ content: 'โŒ Could not parse selected items.' }); + await interaction.editReply({ + content: 'โŒ Could not parse selected items.' + }); return; } try { const res = await apiFetch(Routes.bulkDismantle(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, items }), + body: JSON.stringify({ discordId: interaction.user.id, items }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Bulk dismantle failed.') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Bulk dismantle failed.') + }); return; } @@ -47,10 +76,9 @@ export default class BulkDismantleModal extends ModalSubmit { content: `๐Ÿ”ฅ **Bulk Dismantle Complete!** ${body.message}\n๐Ÿ”ฅ Total Embers: **${body.newEmbers?.toLocaleString() ?? '???'}**` }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/components/modals/BulkSellModal.ts b/src/components/modals/BulkSellModal.ts index 50813eb..b9b8a25 100644 --- a/src/components/modals/BulkSellModal.ts +++ b/src/components/modals/BulkSellModal.ts @@ -1,16 +1,27 @@ -import { ModalSubmitInteraction, Client, MessageFlags } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { + type ModalSubmitInteraction, + type Client, + MessageFlags +} from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class BulkSellModal extends ModalSubmit { - constructor() { super('bulk_sell_modal'); } + constructor() { + super({ customId: 'bulk_sell_modal', cooldown: 5, isAuthorOnly: true }); + } - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const selectedValues = interaction.fields.getStringSelectValues('bulk_sell_select'); + const selectedValues = + interaction.fields.getStringSelectValues('bulk_sell_select'); if (!selectedValues || selectedValues.length === 0) { await interaction.editReply({ content: 'โŒ No items were selected.' }); @@ -18,29 +29,42 @@ export default class BulkSellModal extends ModalSubmit { } // Parse values: "docId-itemId-quantity" or legacy "itemId-quantity" - const items = selectedValues.map(val => { - const parts = val.split('-'); - if (parts.length >= 3) { - return { inventoryId: parts[0], itemId: parseInt(parts[1], 10), amount: parseInt(parts[2], 10) }; - } - return { itemId: parseInt(parts[0], 10), amount: parseInt(parts[1], 10) }; - }).filter(i => !isNaN(i.itemId) && !isNaN(i.amount) && i.amount > 0); + const items = selectedValues + .map((val) => { + const parts = val.split('-'); + if (parts.length >= 3) { + return { + inventoryId: parts[0], + itemId: parseInt(parts[1], 10), + amount: parseInt(parts[2], 10) + }; + } + return { + itemId: parseInt(parts[0], 10), + amount: parseInt(parts[1], 10) + }; + }) + .filter((i) => !isNaN(i.itemId) && !isNaN(i.amount) && i.amount > 0); if (items.length === 0) { - await interaction.editReply({ content: 'โŒ Could not parse selected items.' }); + await interaction.editReply({ + content: 'โŒ Could not parse selected items.' + }); return; } try { const res = await apiFetch(Routes.bulkSell(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, items }), + body: JSON.stringify({ discordId: interaction.user.id, items }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Bulk sell failed.') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Bulk sell failed.') + }); return; } @@ -48,10 +72,9 @@ export default class BulkSellModal extends ModalSubmit { content: `๐Ÿช™ **Bulk Sell Complete!** ${body.message}\n๐Ÿ’ฐ New Balance: **${body.newBalance?.toLocaleString() ?? '???'}** gold` }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public cooldown(): number { return 5; } - public isAuthorOnly(): boolean { return true; } } diff --git a/src/components/modals/CollectModal.ts b/src/components/modals/CollectModal.ts index e521745..0792e31 100644 --- a/src/components/modals/CollectModal.ts +++ b/src/components/modals/CollectModal.ts @@ -1,16 +1,20 @@ -import { ModalSubmitInteraction, Client } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ModalSubmitInteraction, Client } from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class CollectModal extends ModalSubmit { constructor() { - super('collect'); + super({ customId: 'collect', cooldown: 2, isAuthorOnly: true }); } // customId format: collect: - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; @@ -18,29 +22,50 @@ export default class CollectModal extends ModalSubmit { const parsedAmount = parseInt(amount, 10); if (!docId || isNaN(parsedAmount)) { - await interaction.editReply({ content: 'Invalid input! Please try again.', files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: 'Invalid input! Please try again.', + files: [], + components: [], + embeds: [] + }); return; } try { const res = await apiFetch(Routes.collectionAdd(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, inventoryId: docId, amount: parsedAmount }), + body: JSON.stringify({ + discordId: interaction.user.id, + inventoryId: docId, + amount: parsedAmount + }) }); const { success, message, error } = await res.json(); if (!res.ok || !success) { - await interaction.editReply({ content: formatError(error ?? 'Collection failed'), components: [], files: [], embeds: [] }); + await interaction.editReply({ + content: formatError(error ?? 'Collection failed'), + components: [], + files: [], + embeds: [] + }); return; } - await interaction.editReply({ content: message ?? 'Items collected!', components: [], files: [], embeds: [] }); + await interaction.editReply({ + content: message ?? 'Items collected!', + components: [], + files: [], + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), components: [], files: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + components: [], + files: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 2; } } diff --git a/src/components/modals/ConsumeModal.ts b/src/components/modals/ConsumeModal.ts index 5ba5d3f..b38eb80 100644 --- a/src/components/modals/ConsumeModal.ts +++ b/src/components/modals/ConsumeModal.ts @@ -1,16 +1,20 @@ -import { ModalSubmitInteraction, Client } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ModalSubmitInteraction, Client } from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class ConsumeModal extends ModalSubmit { constructor() { - super('consume'); + super({ customId: 'consume', cooldown: 3, isAuthorOnly: true }); } // customId format: consume: - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; @@ -18,29 +22,47 @@ export default class ConsumeModal extends ModalSubmit { const parsedAmount = parseInt(amount, 10); if (!docId || isNaN(parsedAmount)) { - await interaction.editReply({ content: 'Invalid input! Please try again.' }); + await interaction.editReply({ + content: 'Invalid input! Please try again.' + }); return; } try { const res = await apiFetch(Routes.consume(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, inventoryId: docId, amount: parsedAmount }), + body: JSON.stringify({ + discordId: interaction.user.id, + inventoryId: docId, + amount: parsedAmount + }) }); const { success, message, error } = await res.json(); if (!res.ok || !success) { - await interaction.editReply({ content: formatError(error ?? 'Consume failed'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(error ?? 'Consume failed'), + files: [], + components: [], + embeds: [] + }); return; } - await interaction.editReply({ content: message ?? 'Item consumed!', components: [], files: [], embeds: [] }); + await interaction.editReply({ + content: message ?? 'Item consumed!', + components: [], + files: [], + embeds: [] + }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/modals/MarketSellModal.ts b/src/components/modals/MarketSellModal.ts index 3bbbac2..b99edfb 100644 --- a/src/components/modals/MarketSellModal.ts +++ b/src/components/modals/MarketSellModal.ts @@ -1,17 +1,25 @@ -import { ModalSubmitInteraction, Client, MessageFlags } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; -import ItemManager from "../../managers/ItemManager"; +import { + type ModalSubmitInteraction, + type Client, + MessageFlags +} from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; +import * as ItemManager from '../../managers/ItemManager'; export default class MarketSellModal extends ModalSubmit { constructor() { - super('mkt_sell_modal'); + super({ customId: 'mkt_sell_modal', cooldown: 5, isAuthorOnly: true }); } // customId format: mkt_sell_modal:: - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const docId = args?.[0]; @@ -24,17 +32,23 @@ export default class MarketSellModal extends ModalSubmit { const pricePerUnit = parseInt(priceRaw, 10); if (!docId || isNaN(itemId)) { - await interaction.editReply({ content: 'โŒ Error parsing item data. Try again from `/market sell`.' }); + await interaction.editReply({ + content: 'โŒ Error parsing item data. Try again from `/market sell`.' + }); return; } if (isNaN(quantity) || quantity < 1) { - await interaction.editReply({ content: 'โŒ Invalid quantity. Enter a number 1 or higher.' }); + await interaction.editReply({ + content: 'โŒ Invalid quantity. Enter a number 1 or higher.' + }); return; } if (isNaN(pricePerUnit) || pricePerUnit < 1) { - await interaction.editReply({ content: 'โŒ Invalid price. Enter a number 1 or higher.' }); + await interaction.editReply({ + content: 'โŒ Invalid price. Enter a number 1 or higher.' + }); return; } @@ -46,20 +60,25 @@ export default class MarketSellModal extends ModalSubmit { itemId, inventoryId: docId, quantity, - pricePerUnit, - }), + pricePerUnit + }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to create listing') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to create listing') + }); return; } const def = ItemManager.get(itemId); const itemName = def?.name ?? `Item #${itemId}`; - const enhTag = body.listing?.enhanceLevel > 0 ? ` (+${body.listing.enhanceLevel})` : ''; + const enhTag = + body.listing?.enhanceLevel > 0 + ? ` (+${body.listing.enhanceLevel})` + : ''; const totalGold = quantity * pricePerUnit; await interaction.editReply({ @@ -70,14 +89,13 @@ export default class MarketSellModal extends ModalSubmit { `๐Ÿช™ Price: **${pricePerUnit.toLocaleString()}** gold each`, `๐Ÿ’ฐ Total if sold: **${totalGold.toLocaleString()}** gold (5% tax applies)`, ``, - `Use \`/market listings\` to view or cancel your listings.`, - ].join('\n'), + `Use \`/market listings\` to view or cancel your listings.` + ].join('\n') }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } } diff --git a/src/components/modals/SellModal.ts b/src/components/modals/SellModal.ts index fbba15e..c18991c 100644 --- a/src/components/modals/SellModal.ts +++ b/src/components/modals/SellModal.ts @@ -1,16 +1,20 @@ -import { ModalSubmitInteraction, Client } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import type { ModalSubmitInteraction, Client } from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class SellModal extends ModalSubmit { constructor() { - super('sell'); + super({ customId: 'sell', cooldown: 3, isAuthorOnly: true }); } // customId format: sell: - public async execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise { await interaction.deferUpdate(); const docId = args?.[0]; @@ -18,32 +22,47 @@ export default class SellModal extends ModalSubmit { const parsedAmount = parseInt(amount, 10); if (!docId || isNaN(parsedAmount)) { - await interaction.editReply({ content: 'Invalid input! Please try again.' }); + await interaction.editReply({ + content: 'Invalid input! Please try again.' + }); return; } try { const res = await apiFetch(Routes.sell(), { method: 'POST', - body: JSON.stringify({ discordId: interaction.user.id, inventoryId: docId, amount: parsedAmount }), + body: JSON.stringify({ + discordId: interaction.user.id, + inventoryId: docId, + amount: parsedAmount + }) }); const { success, message, newBalance, error } = await res.json(); if (!res.ok || !success) { - await interaction.editReply({ content: formatError(error ?? 'Sell failed'), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(error ?? 'Sell failed'), + files: [], + components: [], + embeds: [] + }); return; } await interaction.editReply({ content: `${message}\n๐Ÿ’ฐ New Balance: **${newBalance?.toLocaleString() ?? '???'}** gold`, - components: [], files: [], embeds: [], + components: [], + files: [], + embeds: [] }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code), files: [], components: [], embeds: [] }); + await interaction.editReply({ + content: formatError(err.message, err.code), + files: [], + components: [], + embeds: [] + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 3; } } diff --git a/src/components/modals/SkillPointsModal.ts b/src/components/modals/SkillPointsModal.ts index 8520ac1..35d0c60 100644 --- a/src/components/modals/SkillPointsModal.ts +++ b/src/components/modals/SkillPointsModal.ts @@ -1,13 +1,22 @@ -import { ModalSubmitInteraction, Client, MessageFlags } from "discord.js"; -import ModalSubmit from "../../structures/ModalSubmit"; -import { apiFetch } from "../../utilities/ApiClient"; -import { formatError } from "../../utilities/ErrorMessages"; -import Routes from "../../utilities/Routes"; +import { + type ModalSubmitInteraction, + type Client, + MessageFlags +} from 'discord.js'; +import ModalSubmit from '../../structures/ModalSubmit'; +import { apiFetch } from '../../utilities/ApiClient'; +import { formatError } from '../../utilities/ErrorMessages'; +import * as Routes from '../../utilities/Routes'; export default class SkillPointsModal extends ModalSubmit { - constructor() { super('skillpoints_modal'); } + constructor() { + super({ customId: 'skillpoints_modal', cooldown: 5, isAuthorOnly: true }); + } - public async execute(interaction: ModalSubmitInteraction, client: Client): Promise { + public async execute( + interaction: ModalSubmitInteraction, + client: Client + ): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const atkRaw = interaction.fields.getTextInputValue('sp_atk').trim(); @@ -17,12 +26,17 @@ export default class SkillPointsModal extends ModalSubmit { const defAmount = parseInt(defRaw, 10) || 0; if (atkAmount < 0 || defAmount < 0) { - await interaction.editReply({ content: 'โŒ Point values cannot be negative.' }); + await interaction.editReply({ + content: 'โŒ Point values cannot be negative.' + }); return; } if (atkAmount === 0 && defAmount === 0) { - await interaction.editReply({ content: 'โŒ You didn\'t allocate any points. Enter a number in at least one field.' }); + await interaction.editReply({ + content: + "โŒ You didn't allocate any points. Enter a number in at least one field." + }); return; } @@ -34,12 +48,14 @@ export default class SkillPointsModal extends ModalSubmit { if (atkAmount > 0) { const res = await apiFetch(Routes.allocate(), { method: 'POST', - body: JSON.stringify({ discordId, stat: 'atk', amount: atkAmount }), + body: JSON.stringify({ discordId, stat: 'atk', amount: atkAmount }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to allocate ATK points') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to allocate ATK points') + }); return; } results.push(`โš”๏ธ **+${atkAmount} ATK** โ†’ Now: ${body.newStats.atk}`); @@ -49,12 +65,14 @@ export default class SkillPointsModal extends ModalSubmit { if (defAmount > 0) { const res = await apiFetch(Routes.allocate(), { method: 'POST', - body: JSON.stringify({ discordId, stat: 'def', amount: defAmount }), + body: JSON.stringify({ discordId, stat: 'def', amount: defAmount }) }); const body = await res.json(); if (!res.ok || !body.success) { - await interaction.editReply({ content: formatError(body.error ?? 'Failed to allocate DEF points') }); + await interaction.editReply({ + content: formatError(body.error ?? 'Failed to allocate DEF points') + }); return; } results.push(`๐Ÿ›ก๏ธ **+${defAmount} DEF** โ†’ Now: ${body.newStats.def}`); @@ -64,10 +82,9 @@ export default class SkillPointsModal extends ModalSubmit { content: `โญ **Skill Points Allocated!**\n\n${results.join('\n')}\n\nRun \`/profile\` to see your updated stats.` }); } catch (err: any) { - await interaction.editReply({ content: formatError(err.message, err.code) }); + await interaction.editReply({ + content: formatError(err.message, err.code) + }); } } - - public isAuthorOnly(): boolean { return true; } - public cooldown(): number { return 5; } -} \ No newline at end of file +} diff --git a/src/events/ClientReadyEvent.ts b/src/events/ClientReadyEvent.ts index cbf9a04..62fb16a 100644 --- a/src/events/ClientReadyEvent.ts +++ b/src/events/ClientReadyEvent.ts @@ -1,19 +1,24 @@ -import { Client } from "discord.js"; -import Event from "../structures/Event"; -import logger from "../utilities/Logger"; -import ItemManager from "../managers/ItemManager"; -import WorkerPool from "../utilities/WorkerPool"; -import PresenceManager from "../managers/PresenceManager"; +import { type Client } from 'discord.js'; +import Event from '../structures/Event'; +import logger from '../utilities/Logger'; +import * as ItemManager from '../managers/ItemManager'; +import * as WorkerPool from '../utilities/WorkerPool'; +import * as PresenceManager from '../managers/PresenceManager'; export default class ClientReadyEvent extends Event { constructor() { - super('clientReady'); + super({ + name: 'clientReady', + isOnce: true + }); } - public async execute(client: Client) { + public async execute(client: Client): Promise { // Use cluster id from hybrid sharding, fallback to shard id const clusterId = (client as any).cluster?.id ?? client.shard?.ids[0] ?? 0; - logger.info(`[${this.constructor.name}] Successfully logged in as ${client.user?.tag}! (Cluster ${clusterId})`); + logger.info( + `[${this.constructor.name}] Successfully logged in as ${client.user?.tag}! (Cluster ${clusterId})` + ); // CRITICAL: Signal to the ClusterManager that this cluster is ready // Without this, the manager will timeout waiting for this cluster @@ -23,18 +28,16 @@ export default class ClientReadyEvent extends Event { WorkerPool.init(); // Stagger API requests by cluster ID to prevent slamming capi.gg - const delayMs = 1500 + (clusterId * 2500); - + const delayMs = 1500 + clusterId * 2500; + setTimeout(async () => { - logger.info(`[Cluster ${clusterId}] Initiating staggered ItemManager sync...`); - await ItemManager.refresh(); + logger.info( + `[Cluster ${clusterId}] Initiating staggered ItemManager sync...` + ); + await ItemManager.refresh(); - // Start rotating presence after items are loaded - await PresenceManager.init(client); + // Start rotating presence after items are loaded + await PresenceManager.init(client); }, delayMs); } - - public isOnce(): boolean { - return true; - } } diff --git a/src/events/GuildCreateEvent.ts b/src/events/GuildCreateEvent.ts index 0b8780e..2f47419 100644 --- a/src/events/GuildCreateEvent.ts +++ b/src/events/GuildCreateEvent.ts @@ -1,32 +1,52 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, Colors, ContainerBuilder, EmbedBuilder, Events, Guild, MessageFlags, TextChannel } from "discord.js"; -import Event from "../structures/Event"; -import logger from "../utilities/Logger"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type Client, + Colors, + ContainerBuilder, + EmbedBuilder, + Events, + type Guild, + MessageFlags, + type TextChannel +} from 'discord.js'; +import Event from '../structures/Event'; +import logger from '../utilities/Logger'; export default class GuildCreateEvent extends Event { constructor() { - super(Events.GuildCreate); + super({ + name: Events.GuildCreate, + isOnce: false + }); } public async execute(guild: Guild, client: Client): Promise { // 1. Log to your private channel try { - const logChannel = await client.channels.fetch('1473407797289816074') as TextChannel; + const logChannel = (await client.channels.fetch( + '1473407797289816074' + )) as TextChannel; if (logChannel && guild) { - const container = new ContainerBuilder().setAccentColor(Colors.Green) - .addSectionComponents( - (section) => - section.setThumbnailAccessory((t) => t.setURL(guild.iconURL() ?? client.user?.avatarURL()!)) - .addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('## I Joined A New Server!'), - (textDisplay) => - textDisplay.setContent(`Joined the ${guild.name} server! It has ${guild.memberCount.toLocaleString()} members.`), - (textDisplay) => - textDisplay.setContent(`-# ID: \`${guild.id}\``) - ) + const container = new ContainerBuilder() + .setAccentColor(Colors.Green) + .addSectionComponents((section) => section + .setThumbnailAccessory((t) => t.setURL(guild.iconURL() ?? client.user?.avatarURL()!) + ) + .addTextDisplayComponents( + (textDisplay) => textDisplay.setContent('## I Joined A New Server!'), + (textDisplay) => textDisplay.setContent( + `Joined the ${guild.name} server! It has ${guild.memberCount.toLocaleString()} members.` + ), + (textDisplay) => textDisplay.setContent(`-# ID: \`${guild.id}\``) + ) ); - await logChannel.send({ components: [container], flags: MessageFlags.IsComponentsV2 }); + await logChannel.send({ + components: [container], + flags: MessageFlags.IsComponentsV2 + }); } } catch (e) { logger.error(e); @@ -35,24 +55,28 @@ export default class GuildCreateEvent extends Event { // 2. Send a welcome embed to the guild try { // Try system channel first, then the first text channel the bot can write in - const targetChannel = guild.systemChannel - ?? guild.channels.cache - .filter(c => c.isTextBased() && c.permissionsFor(guild.members.me!)?.has('SendMessages')) - .first() as TextChannel | undefined; + const targetChannel = + guild.systemChannel ?? + (guild.channels.cache + .filter( + (c) => c.isTextBased() && + c.permissionsFor(guild.members.me!)?.has('SendMessages') + ) + .first() as TextChannel | undefined); if (!targetChannel) return; const embed = new EmbedBuilder() .setColor(0x10b981) - .setTitle('โš”๏ธ Dragon\'s Fall Online') + .setTitle("โš”๏ธ Dragon's Fall Online") .setDescription( 'Thanks for adding DFO! A lightweight text-based MMORPG where you can collect thousands of unique items, explore endless scenarios, and watch numbers go up.\n\n' + - '**Get started in 30 seconds:**\n' + - '> 1. `/register` โ€” Create your character\n' + - '> 2. `/explore` โ€” Venture into the world\n' + - '> 3. `/profile` โ€” Check your stats\n' + - '> 4. `/help` โ€” See all commands\n\n' + - 'You can also play on the web at **[capi.gg/dfo](https://capi.gg/dfo)**' + '**Get started in 30 seconds:**\n' + + '> 1. `/register` โ€” Create your character\n' + + '> 2. `/explore` โ€” Venture into the world\n' + + '> 3. `/profile` โ€” Check your stats\n' + + '> 4. `/help` โ€” See all commands\n\n' + + 'You can also play on the web at **[capi.gg/dfo](https://capi.gg/dfo)**' ) .setThumbnail(client.user?.displayAvatarURL() ?? '') .setFooter({ text: 'DFO Cross-Platform Integration' }); @@ -67,19 +91,17 @@ export default class GuildCreateEvent extends Event { .setLabel('Support Server') .setStyle(ButtonStyle.Link) .setURL('https://discord.gg/3MJkKkh99q') - .setEmoji('๐Ÿ’ฌ'), + .setEmoji('๐Ÿ’ฌ') ); await targetChannel.send({ embeds: [embed], components: [row] }); } catch (e) { // Not critical โ€” some guilds block bot messages in all channels - logger.warn(`[GuildCreate] Could not send welcome message to ${guild.name}: ${e}`); + logger.warn( + `[GuildCreate] Could not send welcome message to ${guild.name}: ${e}` + ); } logger.info(`Joined a new guild! ${guild.name} (${guild.id})`); } - - public isOnce(): boolean { - return false; - } -} \ No newline at end of file +} diff --git a/src/events/InteractionCreateEvent.ts b/src/events/InteractionCreateEvent.ts index ed44d89..3464fa6 100644 --- a/src/events/InteractionCreateEvent.ts +++ b/src/events/InteractionCreateEvent.ts @@ -1,28 +1,40 @@ -import { BaseInteraction, InteractionReplyOptions, MessageFlags, Client } from "discord.js"; -import Event from "../structures/Event"; -import SlashCommandHandler from "../handlers/SlashCommandHandler"; -import logger from "../utilities/Logger"; -import ButtonHandler from "../handlers/ButtonHandler"; -import SelectMenuHandler from "../handlers/SelectMenuHandler"; -import ModalSubmitHandler from "../handlers/ModalSubmitHandler"; -import { formatError } from "../utilities/ErrorMessages"; -import { ApiError } from "../utilities/ApiClient"; +import { + type BaseInteraction, + type InteractionReplyOptions, + MessageFlags, + type Client +} from 'discord.js'; +import Event from '../structures/Event'; +import * as SlashCommandHandler from '../handlers/SlashCommandHandler'; +import logger from '../utilities/Logger'; +import * as ButtonHandler from '../handlers/ButtonHandler'; +import * as SelectMenuHandler from '../handlers/SelectMenuHandler'; +import * as ModalSubmitHandler from '../handlers/ModalSubmitHandler'; +import { formatError } from '../utilities/ErrorMessages'; +import { ApiError } from '../utilities/ApiClient'; export default class InteractionCreateEvent extends Event { constructor() { - super('interactionCreate'); + super({ + name: 'interactionCreate', + isOnce: false + }); } - private async handleError(interaction: BaseInteraction, err: any) { + private async handleError( + interaction: BaseInteraction, + err: any + ): Promise { logger.error(err); if (interaction.isAutocomplete()) return; if (!interaction.isRepliable()) return; // Use themed error messages for API errors, fallback for unknown errors - const message = (err instanceof ApiError) - ? formatError(err.message, err.code) - : formatError(err.message || String(err)); + const message = + err instanceof ApiError + ? formatError(err.message, err.code) + : formatError(err.message || String(err)); const payload: InteractionReplyOptions = { content: message, @@ -40,27 +52,42 @@ export default class InteractionCreateEvent extends Event { } } - public async execute(interaction: BaseInteraction, client: Client): Promise { + public async execute( + interaction: BaseInteraction, + client: Client + ): Promise { if (interaction.user.bot) return; try { if (interaction.isChatInputCommand()) { - await SlashCommandHandler.handle(interaction.commandName, interaction, client); + await SlashCommandHandler.handle( + interaction.commandName, + interaction, + client + ); } else if (interaction.isButton()) { await ButtonHandler.handle(interaction.customId, interaction, client); } else if (interaction.isAnySelectMenu()) { - await SelectMenuHandler.handle(interaction.customId, interaction, client); + await SelectMenuHandler.handle( + interaction.customId, + interaction, + client + ); } else if (interaction.isModalSubmit()) { - await ModalSubmitHandler.handle(interaction.customId, interaction, client); + await ModalSubmitHandler.handle( + interaction.customId, + interaction, + client + ); } else if (interaction.isAutocomplete()) { - await SlashCommandHandler.autocomplete(interaction.commandName, interaction, client); + await SlashCommandHandler.autocomplete( + interaction.commandName, + interaction, + client + ); } } catch (err) { await this.handleError(interaction, err); } } - - public isOnce(): boolean { - return false; - } -} \ No newline at end of file +} diff --git a/src/handlers/ButtonHandler.ts b/src/handlers/ButtonHandler.ts index dc972ce..626ffa8 100644 --- a/src/handlers/ButtonHandler.ts +++ b/src/handlers/ButtonHandler.ts @@ -1,65 +1,83 @@ -import { ButtonInteraction, Collection, MessageFlags, Client } from "discord.js"; +import { + type ButtonInteraction, + Collection, + MessageFlags, + type Client +} from 'discord.js'; import { readdirSync } from 'fs'; -import { join } from "path"; -import Button from "../structures/Button"; -import logger from "../utilities/Logger"; -import CooldownManager from "../managers/CooldownManager"; -const filePath = join(__dirname, "../components/buttons"); +import { join } from 'path'; +import Button from '../structures/Button'; +import logger from '../utilities/Logger'; +import * as CooldownManager from '../managers/CooldownManager'; +const filePath = join(__dirname, '../components/buttons'); -export default class ButtonHandler { - private static _cache: Collection = new Collection(); +const _cache: Collection = new Collection(); - public static load(): void { - const buttonFiles = readdirSync(filePath).filter(file => - (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') +export function load(): void { + const buttonFiles = readdirSync(filePath).filter( + (file) => (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') + ); + + if (buttonFiles.length < 1) { + logger.info( + `[ButtonHandler] No button executable data to cache. Skipping step` ); + return; + } - if (buttonFiles.length < 1) { - logger.info(`[ButtonHandler] No button executable data to cache. Skipping step`); - return; - } + for (const file of buttonFiles) { + let button = require(join(filePath, file)); + button = new button.default(); + if (!(button instanceof Button)) continue; + _cache.set(button.customId, button); + } - for (const file of buttonFiles) { - let button = require(join(filePath, file)); - button = new button.default(); - if (!(button instanceof Button)) continue; - this._cache.set(button.customId, button); - } + logger.info(`[ButtonHandler] Cached ${_cache.size} button executables`); +} - logger.info(`[ButtonHandler] Cached ${this._cache.size} button executables`); +export async function handle( + customId: string, + interaction: ButtonInteraction, + client: Client +): Promise { + let id = customId; + let target = null; + if (customId.startsWith('page_')) return; + if (customId.includes(':')) { + const [name, ...args] = customId.split(':'); + id = name; + target = args; } - public static async handle(customId: string, interaction: ButtonInteraction, client: Client) { - let id = customId; - let target = null; - if (customId.startsWith('page_')) return; - if (customId.includes(':')) { - const [ name, ...args ] = customId.split(':'); - id = name; - target = args; - } - try { - const button = this._cache.get(id); - if (button == null) { - await interaction.reply({ content: 'This button is no longer supported or has deprecated code!', flags: MessageFlags.Ephemeral }); - return; - } - - if (button.isAuthorOnly() && interaction.user.id !== interaction.message.interactionMetadata?.user.id) return; + const button = _cache.get(id); + if (button == null) { + await interaction.reply({ + content: 'This button is no longer supported or has deprecated code!', + flags: MessageFlags.Ephemeral + }); + return; + } - let key = `b-${id}-${interaction.user.id}`; - if (customId === 'startNewDay') key = `adventure-${interaction.user.id}`; - if (CooldownManager.onCooldown(key)) { - const expireAt = CooldownManager.getExpiration(key); - await interaction.reply({ content: `โณ You can use this button again .`, flags: MessageFlags.Ephemeral }); - return; - } + if ( + button.isAuthorOnly && + interaction.user.id !== interaction.message.interactionMetadata?.user.id + ) + return; - await button.execute(interaction, client, target); - CooldownManager.addCooldown(key, button.cooldown()); - logger.button(`${interaction.user.username} (${interaction.user.id}) used '${customId}'`); - } catch (err) { - throw err; - } + let key = `b-${id}-${interaction.user.id}`; + if (customId === 'startNewDay') key = `adventure-${interaction.user.id}`; + if (CooldownManager.onCooldown(key)) { + const expireAt = CooldownManager.getExpiration(key); + await interaction.reply({ + content: `โณ You can use this button again .`, + flags: MessageFlags.Ephemeral + }); + return; } -} \ No newline at end of file + + await button.execute(interaction, client, target); + CooldownManager.addCooldown(key, button.cooldown); + logger.button( + `${interaction.user.username} (${interaction.user.id}) used '${customId}'` + ); +} diff --git a/src/handlers/EventHandler.ts b/src/handlers/EventHandler.ts index 7b66910..fd24a46 100644 --- a/src/handlers/EventHandler.ts +++ b/src/handlers/EventHandler.ts @@ -1,31 +1,24 @@ import { readdirSync } from 'fs'; import Event from '../structures/Event'; -import { Client } from 'discord.js'; +import { type Client } from 'discord.js'; import { join } from 'path'; const filePath = join(__dirname, '../events'); -export default class EventHandler { - private client: Client; +export default function initializeEventHandler(client: Client): void { + const eventFiles = readdirSync(filePath).filter( + (file) => file.endsWith('.ts') || file.endsWith('.js') || !file.endsWith('.d.ts') + ); - constructor(client: Client) { - this.client = client; + for (const file of eventFiles) { + let event = require(join(filePath, file)); + event = new event.default(); + if (!(event instanceof Event)) continue; - this.initialize(); - } - - private initialize(): void { - const eventFiles = readdirSync(filePath).filter(file => (file.endsWith('.ts') || file.endsWith('.js') || !file.endsWith('.d.ts'))); - - for (const file of eventFiles) { - let event = require(join(filePath, file)); - event = new event.default(); - if (!(event instanceof Event)) continue; - - if (event.isOnce()) { - this.client.once(event.getName(), (...args: any[]) => event.execute(...args, this.client)); - } else { - this.client.on(event.getName(), (...args: any[]) => event.execute(...args, this.client)); - } + if (event.isOnce) { + client.once(event.name, (...args: any[]) => event.execute(...args, client) + ); + } else { + client.on(event.name, (...args: any[]) => event.execute(...args, client)); } } -} \ No newline at end of file +} diff --git a/src/handlers/ModalSubmitHandler.ts b/src/handlers/ModalSubmitHandler.ts index 7b10af1..e4f2774 100644 --- a/src/handlers/ModalSubmitHandler.ts +++ b/src/handlers/ModalSubmitHandler.ts @@ -1,55 +1,64 @@ -import ModalSubmit from "../structures/ModalSubmit"; -import { ModalSubmitInteraction, Collection, Client } from "discord.js"; -import { readdirSync } from "fs"; -import { join } from "path"; -import logger from "../utilities/Logger"; -import CooldownManager from "../managers/CooldownManager"; -const filePath = join(__dirname, "../components/modals"); - -export default class ModalSubmitHandler { - private static _cache: Collection = new Collection(); - - public static load(): void { - const modalFiles = readdirSync(filePath).filter(file => - (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') - ); +import ModalSubmit from '../structures/ModalSubmit'; +import { + type ModalSubmitInteraction, + Collection, + type Client +} from 'discord.js'; +import { readdirSync } from 'fs'; +import { join } from 'path'; +import logger from '../utilities/Logger'; +import * as CooldownManager from '../managers/CooldownManager'; +const filePath = join(__dirname, '../components/modals'); + +const _cache: Collection = new Collection(); - if (modalFiles.length < 1) { - logger.info('[ModalSubmitHandler] No modal submit executable data to cache. Skipping step'); - return; - } +export function load(): void { + const modalFiles = readdirSync(filePath).filter( + (file) => (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') + ); - for (const file of modalFiles) { - let modal = require(join(filePath, file)); - modal = new modal.default(); - if (!(modal instanceof ModalSubmit)) continue; - this._cache.set(modal.customId, modal); - } + if (modalFiles.length < 1) { + logger.info( + '[ModalSubmitHandler] No modal submit executable data to cache. Skipping step' + ); + return; + } - logger.info(`[ModalSubmitHandler] Cached a total of ${this._cache.size} modal executable data`); + for (const file of modalFiles) { + let modal = require(join(filePath, file)); + modal = new modal.default(); + if (!(modal instanceof ModalSubmit)) continue; + _cache.set(modal.customId, modal); } - public static async handle(customId: string, interaction: ModalSubmitInteraction, client: Client) { - let id = customId; - let target = null; - if (customId.includes(':')) { - const [name, ...args] = customId.split(':'); - id = name; - target = args; - } + logger.info( + `[ModalSubmitHandler] Cached a total of ${_cache.size} modal executable data` + ); +} - try { - const modal = this._cache.get(id); - if (modal == null) throw new Error(`No modal executable data could be found for the ID: ${customId}`); +export async function handle( + customId: string, + interaction: ModalSubmitInteraction, + client: Client +): Promise { + let id = customId; + let target = null; + if (customId.includes(':')) { + const [name, ...args] = customId.split(':'); + id = name; + target = args; + } + + const modal = _cache.get(id); + if (modal == null) + throw new Error( + `No modal executable data could be found for the ID: ${customId}` + ); - const key = `m-${customId}-${interaction.user.id}`; + const key = `m-${customId}-${interaction.user.id}`; - if (CooldownManager.onCooldown(key)) return; + if (CooldownManager.onCooldown(key)) return; - await modal.execute(interaction, client, target); - CooldownManager.addCooldown(key, modal.cooldown()); - } catch (err) { - throw err; - } - } -} \ No newline at end of file + await modal.execute(interaction, client, target); + CooldownManager.addCooldown(key, modal.cooldown); +} diff --git a/src/handlers/SelectMenuHandler.ts b/src/handlers/SelectMenuHandler.ts index 56a1409..70a3f75 100644 --- a/src/handlers/SelectMenuHandler.ts +++ b/src/handlers/SelectMenuHandler.ts @@ -1,57 +1,68 @@ -import SelectMenu from "../structures/SelectMenu"; -import { AnySelectMenuInteraction, Collection, Client } from "discord.js"; +import SelectMenu from '../structures/SelectMenu'; +import { + type AnySelectMenuInteraction, + Collection, + type Client +} from 'discord.js'; import { readdirSync } from 'fs'; -import logger from "../utilities/Logger"; -import CooldownManager from "../managers/CooldownManager"; -import { join } from "path"; -const filePath = join(__dirname, "../components/menus"); +import logger from '../utilities/Logger'; +import * as CooldownManager from '../managers/CooldownManager'; +import { join } from 'path'; +const filePath = join(__dirname, '../components/menus'); -export default class SelectMenuHandler { - private static _cache: Collection = new Collection(); +const _cache: Collection = new Collection(); - public static load(): void { - const menuFiles = readdirSync(filePath).filter(file => - (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') +export function load(): void { + const menuFiles = readdirSync(filePath).filter( + (file) => (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') + ); + + if (menuFiles.length < 1) { + logger.info( + `[SelectMenuHandler] No select menu executable data to cache. Skipping step` ); + return; + } - if (menuFiles.length < 1) { - logger.info(`[SelectMenuHandler] No select menu executable data to cache. Skipping step`); - return; - } + for (const file of menuFiles) { + let menu = require(join(filePath, file)); + menu = new menu.default(); + if (!(menu instanceof SelectMenu)) continue; + _cache.set(menu.customId, menu); + } - for (const file of menuFiles) { - let menu = require(join(filePath, file)); - menu = new menu.default(); - if (!(menu instanceof SelectMenu)) continue; - this._cache.set(menu.customId, menu); - } + logger.info(`[SelectMenuHandler] Cached ${_cache.size} menu executables`); +} - logger.info(`[SelectMenuHandler] Cached ${this._cache.size} menu executables`); +export async function handle( + customId: string, + interaction: AnySelectMenuInteraction, + client: Client +): Promise { + let id = customId; + let target = null; + if (customId.includes(':')) { + const [name, ...args] = customId.split(':'); + id = name; + target = args; } - public static async handle(customId: string, interaction: AnySelectMenuInteraction, client: Client) { - let id = customId; - let target = null; - if (customId.includes(':')) { - const [name, ...args] = customId.split(':'); - id = name; - target = args; - } - - try { - const menu = this._cache.get(id); - if (!menu) throw new Error(`No executable data could be found for menu with ID: ${customId}`); + const menu = _cache.get(id); + if (!menu) + throw new Error( + `No executable data could be found for menu with ID: ${customId}` + ); - if (menu.isAuthorOnly() && interaction.user.id !== interaction.message.interactionMetadata?.user.id) return; + if ( + menu.isAuthorOnly && + interaction.user.id !== interaction.message.interactionMetadata?.user.id + ) + return; - const key = `s-${customId}-${interaction.user.id}`; + const key = `s-${customId}-${interaction.user.id}`; - if (CooldownManager.onCooldown(key)) return; + if (CooldownManager.onCooldown(key)) return; - await menu.execute(interaction, client, target); - CooldownManager.addCooldown(key, menu.cooldown()); - } catch (err) { - throw err; - } - } -} \ No newline at end of file + await menu.execute(interaction, client, target); + CooldownManager.addCooldown(key, menu.cooldown); +} diff --git a/src/handlers/SlashCommandHandler.ts b/src/handlers/SlashCommandHandler.ts index 135349f..b607fac 100644 --- a/src/handlers/SlashCommandHandler.ts +++ b/src/handlers/SlashCommandHandler.ts @@ -1,71 +1,82 @@ -import { AutocompleteInteraction, ChatInputCommandInteraction, Collection, MessageFlags, Client } from "discord.js"; +import { + type AutocompleteInteraction, + type ChatInputCommandInteraction, + Collection, + MessageFlags, + type Client +} from 'discord.js'; import { readdirSync } from 'fs'; -import { join } from "path"; -import SlashCommand from "../structures/SlashCommand"; -import logger from "../utilities/Logger"; -import CooldownManager from "../managers/CooldownManager"; +import { join } from 'path'; +import SlashCommand from '../structures/SlashCommand'; +import logger from '../utilities/Logger'; +import * as CooldownManager from '../managers/CooldownManager'; const filePath = join(__dirname, '../commands'); -export default class SlashCommandHandler { - private static _cache: Collection = new Collection(); +export const cache: Collection = new Collection(); - public static getCache(): Collection { - return this._cache; +export function load(): void { + const commandFiles = readdirSync(filePath).filter( + (file) => (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') + ); + + for (const file of commandFiles) { + let command = require(join(filePath, file)); + command = new command.default(); + if (!(command instanceof SlashCommand)) continue; + cache.set(command.data.name, command); } - public static load(): void { - const commandFiles = readdirSync(filePath).filter(file => - (file.endsWith('.ts') || file.endsWith('.js')) && !file.endsWith('.d.ts') - ); + logger.info(`[SlashCommandHandler] Cached a total of ${cache.size} commands`); +} - for (const file of commandFiles) { - let command = require(join(filePath, file)); - command = new command.default(); - if (!(command instanceof SlashCommand)) continue; - const commandData = command.getData(); - this._cache.set(commandData.name, command); - } +export async function handle( + name: string, + interaction: ChatInputCommandInteraction, + client: Client +): Promise { + const startTime = Date.now(); - logger.info(`[SlashCommandHandler] Cached a total of ${this._cache.size} commands`); + const command = cache.get(name); + if (!command) { + await interaction.reply({ + content: 'This command is outdated or disabled', + flags: MessageFlags.Ephemeral + }); + return; } - public static async handle(name: string, interaction: ChatInputCommandInteraction, client: Client): Promise { - const startTime = Date.now(); + const key = `${name}-${interaction.user.id}`; - try { - const command = this._cache.get(name); - if (!command) { - await interaction.reply({ content: "This command is outdated or disabled", flags: MessageFlags.Ephemeral }); - return; - } + if (CooldownManager.onCooldown(key)) { + const expiresAt = CooldownManager.getExpiration(key); + await interaction.reply({ + content: `โณ You can use this command again .`, + flags: MessageFlags.Ephemeral + }); + return; + } - const key = `${name}-${interaction.user.id}`; + await command.execute(interaction, client); - if (CooldownManager.onCooldown(key)) { - const expiresAt = CooldownManager.getExpiration(key); - await interaction.reply({ content: `โณ You can use this command again .`, flags: MessageFlags.Ephemeral }); - return; - } + CooldownManager.addCooldown(key, command.cooldown); + logger.command( + `/${name} | ${interaction.user.username} (${interaction.user.id}) | ${interaction.guild?.name ?? 'DM'} | ${Date.now() - startTime}ms` + ); +} - await command.execute(interaction, client); +export async function autocomplete( + name: string, + interaction: AutocompleteInteraction, + client: Client +): Promise { + const command = cache.get(name); + if (!command) return; - CooldownManager.addCooldown(key, command.cooldown()); - logger.command(`/${name} | ${interaction.user.username} (${interaction.user.id}) | ${interaction.guild?.name ?? 'DM'} | ${Date.now() - startTime}ms`); + if (command.autocomplete) { + try { + await command.autocomplete(interaction, client); } catch (err) { - throw err; - } - } - - public static async autocomplete(name: string, interaction: AutocompleteInteraction, client: Client): Promise { - const command = this._cache.get(name); - if (!command) return; - - if (command.autocomplete) { - try { - await command.autocomplete(interaction, client); - } catch (err) { - logger.error(`Autocomplete failed for ${name}: ${err}`); - } + logger.error(`Autocomplete failed for ${name}: ${err}`); } } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index fbffe12..7fc498e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,41 +22,48 @@ const isCompiled = __filename.endsWith('.js'); const botFile = path.join(__dirname, isCompiled ? 'bot.js' : 'bot.ts'); const manager = new ClusterManager(botFile, { - token: process.env.BOT_TOKEN, - totalShards: 'auto', // Let Discord decide shard count - shardsPerClusters: 2, // 2 internal shards per cluster process - totalClusters: 'auto', // Auto-calculate cluster count - mode: 'process', // Each cluster is a separate process - respawn: true, - restarts: { - max: 10, // Max restarts per cluster - interval: 60000 * 60, // Reset restart counter every hour - }, - queue: { - auto: true, // Automatically manage spawn queue - timeout: 60000, // 60s timeout per cluster spawn - }, - execArgv: isCompiled ? [] : ['-r', 'ts-node/register'], + token: process.env.BOT_TOKEN, + totalShards: 'auto', // Let Discord decide shard count + shardsPerClusters: 2, // 2 internal shards per cluster process + totalClusters: 'auto', // Auto-calculate cluster count + mode: 'process', // Each cluster is a separate process + respawn: true, + restarts: { + max: 10, // Max restarts per cluster + interval: 60000 * 60 // Reset restart counter every hour + }, + queue: { + auto: true, // Automatically manage spawn queue + timeout: 60000 // 60s timeout per cluster spawn + }, + execArgv: isCompiled ? [] : ['-r', 'ts-node/register'] }); manager.on('clusterCreate', (cluster) => { - logger.info(`[System] Launched Cluster #${cluster.id} (Shards: ${cluster.shardList.join(', ')})`); + logger.info( + `[System] Launched Cluster #${cluster.id} (Shards: ${cluster.shardList.join(', ')})` + ); - cluster.on('error', (err) => { - logger.error(`[Cluster #${cluster.id}] Error: ${err}`); - }); + cluster.on('error', (err) => { + logger.error(`[Cluster #${cluster.id}] Error: ${err}`); + }); - cluster.on('death', () => { - logger.error(`[Cluster #${cluster.id}] Died. Respawning...`); - }); + cluster.on('death', () => { + logger.error(`[Cluster #${cluster.id}] Died. Respawning...`); + }); - cluster.on('message', (message: any) => { - if (message && typeof message === 'object' && 'type' in message && message.type === 'log') { - logger.info(`[Cluster #${cluster.id}] ${message.content}`); - } - }); + cluster.on('message', (message: any) => { + if ( + message && + typeof message === 'object' && + 'type' in message && + message.type === 'log' + ) { + logger.info(`[Cluster #${cluster.id}] ${message.content}`); + } + }); }); -manager.spawn().catch(err => { - logger.error(`[System] Failed to spawn clusters: ${err}`); +manager.spawn().catch((err) => { + logger.error(`[System] Failed to spawn clusters: ${err}`); }); diff --git a/src/interfaces/ICollectionJSON.ts b/src/interfaces/ICollectionJSON.ts index 1035cfc..7be06a8 100644 --- a/src/interfaces/ICollectionJSON.ts +++ b/src/interfaces/ICollectionJSON.ts @@ -1,8 +1,8 @@ export interface ICollectionJSON { - userId: string; - items: Map, - totalItemsCollected: number; - uniqueItemsFound: number; - createdAt: Date; - updatedAt: Date; -} \ No newline at end of file + userId: string; + items: Map; + totalItemsCollected: number; + uniqueItemsFound: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/interfaces/ICombatJSON.ts b/src/interfaces/ICombatJSON.ts index 87a48f1..c62fb4e 100644 --- a/src/interfaces/ICombatJSON.ts +++ b/src/interfaces/ICombatJSON.ts @@ -1,5 +1,5 @@ -import { IEnemyJSON } from "./IEnemyJSON"; -import { IItemJSON } from "./IItemJSON"; +import { type IEnemyJSON } from './IEnemyJSON'; +import { type IItemJSON } from './IItemJSON'; export interface ICombatJSON { success: boolean; @@ -15,4 +15,4 @@ export interface ICombatJSON { levelsGained: number; }; error?: string; -}; \ No newline at end of file +} diff --git a/src/interfaces/ICooldown.ts b/src/interfaces/ICooldown.ts deleted file mode 100644 index 0ceea96..0000000 --- a/src/interfaces/ICooldown.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default interface ICooldown { - cooldown(): number; -} \ No newline at end of file diff --git a/src/interfaces/IEnemyJSON.ts b/src/interfaces/IEnemyJSON.ts index 5f7ece0..436ce4d 100644 --- a/src/interfaces/IEnemyJSON.ts +++ b/src/interfaces/IEnemyJSON.ts @@ -16,4 +16,4 @@ export interface IEnemyJSON { isMythic: boolean; isChampion: boolean; isElite: boolean; -} \ No newline at end of file +} diff --git a/src/interfaces/IExecutable.ts b/src/interfaces/IExecutable.ts index 3e433c8..0561846 100644 --- a/src/interfaces/IExecutable.ts +++ b/src/interfaces/IExecutable.ts @@ -1,3 +1,3 @@ export default interface IExecutable { - execute(...args: any[]): Promise; -} \ No newline at end of file + execute: (...args: any[]) => Promise; +} diff --git a/src/interfaces/IGameJSON.ts b/src/interfaces/IGameJSON.ts index 841ab49..120d2e0 100644 --- a/src/interfaces/IGameJSON.ts +++ b/src/interfaces/IGameJSON.ts @@ -1,9 +1,9 @@ // ========== TASKS ========== export interface ITaskJSON { - id: string; // API returns `id`, not `taskId` - action: string; // e.g. "EXPLORE_STEPS", "DEFEAT_ENEMIES" - label: string; // e.g. "Explore 50 times" (was `description`) + id: string; // API returns `id`, not `taskId` + action: string; // e.g. "EXPLORE_STEPS", "DEFEAT_ENEMIES" + label: string; // e.g. "Explore 50 times" (was `description`) icon: string; target: number; progress: number; @@ -15,8 +15,8 @@ export interface ITaskJSON { chestTier: string | null; }; completed: boolean; - claimed: boolean; // API returns `claimed`, not `isClaimed` - nextReset: string; // ISO date string + claimed: boolean; // API returns `claimed`, not `isClaimed` + nextReset: string; // ISO date string } export interface ITasksResponse { @@ -24,7 +24,7 @@ export interface ITasksResponse { tasks: ITaskJSON[]; embers: number; resets: { - daily: string; // ISO date strings, not numbers + daily: string; // ISO date strings, not numbers weekly: string; monthly: string; }; @@ -58,7 +58,7 @@ export interface IChestOpenResult { success: boolean; message: string; loot: { - items: Array<{ name: string; rarity: string; level: number; type: string }>; + items: { name: string; rarity: string; level: number; type: string }[]; gold: number; embers: number; isPity: boolean; diff --git a/src/interfaces/IInventoryJSON.ts b/src/interfaces/IInventoryJSON.ts index 89f68b7..2088399 100644 --- a/src/interfaces/IInventoryJSON.ts +++ b/src/interfaces/IInventoryJSON.ts @@ -1,13 +1,13 @@ export interface IInventoryItem { - _id: string; // MongoDB document ID โ€” used for variant targeting + _id: string; // MongoDB document ID โ€” used for variant targeting userId: string; itemId: number; quantity: number; isLocked: boolean; - enhanceLevel: number; // 0 = base, 1-10 = enhanced + enhanceLevel: number; // 0 = base, 1-10 = enhanced statOverrides: { atk: number; def: number; hp: number } | null; - affixOverrides: Array<{ type: string; value: number }> | null; + affixOverrides: { type: string; value: number }[] | null; petLevel: number; createdAt: Date; updatedAt: Date; -} \ No newline at end of file +} diff --git a/src/interfaces/IItemJSON.ts b/src/interfaces/IItemJSON.ts index 055c218..e3c6b76 100644 --- a/src/interfaces/IItemJSON.ts +++ b/src/interfaces/IItemJSON.ts @@ -1,4 +1,10 @@ -export type ItemType = 'Weapon' | 'Armor' | 'Accessory' | 'Consumable' | 'Material' | 'Collectible'; +export type ItemType = + | 'Weapon' + | 'Armor' + | 'Accessory' + | 'Consumable' + | 'Material' + | 'Collectible'; export type EffectType = 'HEAL_HP' | 'GRANT_XP' | 'GRANT_GOLD' | 'NONE'; export type Rarity = | 'Common' @@ -11,9 +17,14 @@ export type Rarity = | 'Exotic'; export const RARITY_COLORS: Record = { - Common: 0xb0b0b0, Uncommon: 0x2ecc71, Rare: 0x3498db, - Elite: 0xe67e22, Epic: 0x9b59b6, Legendary: 0xf1c40f, - Divine: 0x00e5ff, Exotic: 0xff00cc + Common: 0xb0b0b0, + Uncommon: 0x2ecc71, + Rare: 0x3498db, + Elite: 0xe67e22, + Epic: 0x9b59b6, + Legendary: 0xf1c40f, + Divine: 0x00e5ff, + Exotic: 0xff00cc }; export type EquipmentSlot = @@ -53,7 +64,7 @@ export interface IItemJSON { def: number; hp: number; }; - affixes?: Array<{ type: ItemAffixes, value: number }>; + affixes?: { type: ItemAffixes; value: number }[]; action: { effect: EffectType; amount: number; @@ -63,4 +74,4 @@ export interface IItemJSON { createdBy: string; createdAt: Date; updatedAt: Date; -} \ No newline at end of file +} diff --git a/src/interfaces/IItemsJSON.ts b/src/interfaces/IItemsJSON.ts index 3fea040..30d4d7a 100644 --- a/src/interfaces/IItemsJSON.ts +++ b/src/interfaces/IItemsJSON.ts @@ -1,7 +1,7 @@ -import { IItemJSON } from "./IItemJSON"; +import { type IItemJSON } from './IItemJSON'; export interface IItemsJSON { success: boolean; count: number; data: IItemJSON[]; -}; \ No newline at end of file +} diff --git a/src/interfaces/INPCJSON.ts b/src/interfaces/INPCJSON.ts index 06a67bb..a036376 100644 --- a/src/interfaces/INPCJSON.ts +++ b/src/interfaces/INPCJSON.ts @@ -2,4 +2,4 @@ export interface INPCJSON { id: number; name: string; description: string; -} \ No newline at end of file +} diff --git a/src/interfaces/IPlayerJSON.ts b/src/interfaces/IPlayerJSON.ts index 3a16b40..745119a 100644 --- a/src/interfaces/IPlayerJSON.ts +++ b/src/interfaces/IPlayerJSON.ts @@ -1,11 +1,11 @@ -import { IEnemyJSON } from "./IEnemyJSON"; +import { type IEnemyJSON } from './IEnemyJSON'; export type Privilege = - | 'Member' - | 'Donator' - | 'Moderator' - | 'Administrator' - | 'Developer'; + | 'Member' + | 'Donator' + | 'Moderator' + | 'Administrator' + | 'Developer'; export interface IPlayerJSON { id: string; @@ -36,12 +36,12 @@ export interface IPlayerJSON { collections: { uniqueClaimed: number; totalClaimed: number; - } + }; discordRoleData?: { accessToken: any; refreshToken: any; expiresAt: any; - } + }; activeEncounter: IEnemyJSON | null; cooldowns: { step: Date; @@ -68,4 +68,4 @@ class PlayerStatistics { playersDefeated: number = 0; timesDied: number = 0; questsDone: number = 0; -} \ No newline at end of file +} diff --git a/src/interfaces/IScenarioJSON.ts b/src/interfaces/IScenarioJSON.ts index 6df4848..c2faafe 100644 --- a/src/interfaces/IScenarioJSON.ts +++ b/src/interfaces/IScenarioJSON.ts @@ -4,4 +4,4 @@ export interface IScenarioJSON { createdBy: string; createdOn: Date; lastUpdated: Date; -} \ No newline at end of file +} diff --git a/src/interfaces/IStepJSON.ts b/src/interfaces/IStepJSON.ts index 192c1d1..2beac87 100644 --- a/src/interfaces/IStepJSON.ts +++ b/src/interfaces/IStepJSON.ts @@ -1,5 +1,5 @@ -import { IEnemyJSON } from "./IEnemyJSON"; -import { IItemJSON } from "./IItemJSON"; +import { type IEnemyJSON } from './IEnemyJSON'; +import { type IItemJSON } from './IItemJSON'; export interface IStepJSON { success: boolean; @@ -20,8 +20,8 @@ export interface IStepRewardsJSON { gold: number; item: IItemJSON | null; levelsGained: number; - chestDrop: string | null; // Chest tier found while exploring - toll: number; // Zone toll deducted this step + chestDrop: string | null; // Chest tier found while exploring + toll: number; // Zone toll deducted this step } export interface IActiveBonuses { @@ -43,4 +43,4 @@ export interface IStepPlayerStatsJSON { gold: number; expRequired: number; activeBonuses: IActiveBonuses; -} \ No newline at end of file +} diff --git a/src/managers/CooldownManager.ts b/src/managers/CooldownManager.ts index 0a01872..ca2b613 100644 --- a/src/managers/CooldownManager.ts +++ b/src/managers/CooldownManager.ts @@ -1,38 +1,34 @@ -import { Collection } from "discord.js"; +import { Collection } from 'discord.js'; -export default class CooldownManager { - private static _cache: Collection = new Collection(); +const _cache: Collection = new Collection(); +const _interval = setInterval(() => prune(), 60_000); - private static _interval = setInterval(() => this.prune(), 60_000); +export function onCooldown(key: string): boolean { + const expiration = _cache.get(key); + if (!expiration) return false; - public static onCooldown(key: string): boolean { - const expiration = this._cache.get(key); - if (!expiration) return false; - - if (expiration > Date.now()) { - return true; - } else { - this._cache.delete(key); - return false; - } + if (expiration > Date.now()) { + return true; } + _cache.delete(key); + return false; +} - public static getExpiration(key: string): number { - const expiration = this._cache.get(key); - if (!expiration) return Math.floor(Date.now() / 1000); +export function getExpiration(key: string): number { + const expiration = _cache.get(key); + if (!expiration) return Math.floor(Date.now() / 1000); - return Math.floor(expiration / 1000); - } + return Math.floor(expiration / 1000); +} - public static addCooldown(key: string, durationInSeconds: number): void { - if (this.onCooldown(key)) return; +export function addCooldown(key: string, durationInSeconds: number): void { + if (onCooldown(key)) return; - const expiresAt = Date.now() + (durationInSeconds * 1000); - this._cache.set(key, expiresAt); - } + const expiresAt = Date.now() + durationInSeconds * 1000; + _cache.set(key, expiresAt); +} - private static prune(): void { - const now = Date.now(); - this._cache.sweep((expiration) => expiration <= now); - } -} \ No newline at end of file +function prune(): void { + const now = Date.now(); + _cache.sweep((expiration) => expiration <= now); +} diff --git a/src/managers/ItemManager.ts b/src/managers/ItemManager.ts index e07f10c..2cfa534 100644 --- a/src/managers/ItemManager.ts +++ b/src/managers/ItemManager.ts @@ -1,117 +1,121 @@ -import { Collection } from "discord.js"; -import { IItemJSON } from "../interfaces/IItemJSON"; -import logger from "../utilities/Logger"; -import Routes from "../utilities/Routes"; +import { Collection } from 'discord.js'; +import { type IItemJSON } from '../interfaces/IItemJSON'; +import logger from '../utilities/Logger'; +import * as Routes from '../utilities/Routes'; import 'dotenv/config'; const REFRESH_INTERVAL = 300_000; // 5 minutes -const FETCH_TIMEOUT = 15_000; // 15 second timeout for API calls - -export default class ItemManager { - public static cache: Collection = new Collection(); - private static isLoaded: boolean = false; - private static isRefreshing: boolean = false; - private static refreshTimer: NodeJS.Timeout | null = null; - - /** - * Fetch all items from the API and populate the cache. - * Uses atomic swap so the cache is never empty mid-refresh โ€” - * live commands always read from a full dataset. - */ - public static async init(): Promise { - // Prevent overlapping fetches (e.g. slow API + interval fires again) - if (this.isRefreshing) { - logger.warn('[ItemManager] Refresh already in progress, skipping'); +const FETCH_TIMEOUT = 15_000; // 15 second timeout for API calls + +export let cache: Collection = new Collection(); +let isLoaded: boolean = false; +let isRefreshing: boolean = false; +let refreshTimer: NodeJS.Timeout | null = null; + +/** + * Fetch all items from the API and populate the cache. + * Uses atomic swap so the cache is never empty mid-refresh โ€” + * live commands always read from a full dataset. + */ +export async function init(): Promise { + // Prevent overlapping fetches (e.g. slow API + interval fires again) + if (isRefreshing) { + logger.warn('[ItemManager] Refresh already in progress, skipping'); + return; + } + + isRefreshing = true; + + try { + const res = await fetch(Routes.items(), { + headers: Routes.HEADERS(), + signal: AbortSignal.timeout(FETCH_TIMEOUT) + }); + + if (!res.ok) { + logger.error( + `[ItemManager] Failed to fetch items: HTTP ${res.status} ${res.statusText}` + ); return; } - this.isRefreshing = true; - - try { - const res = await fetch(Routes.items(), { - headers: Routes.HEADERS(), - signal: AbortSignal.timeout(FETCH_TIMEOUT), - }); - - if (!res.ok) { - logger.error(`[ItemManager] Failed to fetch items: HTTP ${res.status} ${res.statusText}`); - return; - } - - const responseBody = await res.json(); - - if (!responseBody.success || !responseBody.data) { - logger.error('[ItemManager] API returned unexpected payload structure'); - return; - } - - const items: IItemJSON[] = responseBody.data; - - // Atomic swap: build the new cache fully before replacing the old one. - // This ensures any command reading mid-refresh still gets complete data. - const newCache = new Collection(); - for (const item of items) { - newCache.set(item.itemId, item); - } - - this.cache = newCache; - this.isLoaded = true; - - logger.info(`[ItemManager] Synced ${newCache.size} items`); - } catch (error: any) { - // Distinguish timeout from other errors for clearer debugging - if (error.name === 'TimeoutError' || error.name === 'AbortError') { - logger.error(`[ItemManager] Fetch timed out after ${FETCH_TIMEOUT / 1000}s`); - } else { - logger.error(error, '[ItemManager] Critical fetch error:'); - } - } finally { - this.isRefreshing = false; - } - } + const responseBody = await res.json(); - /** - * Start the auto-refresh loop. Safe to call multiple times โ€” - * clears any existing interval before creating a new one. - */ - public static async refresh(): Promise { - // Clear any existing interval to prevent stacking - if (this.refreshTimer) { - clearInterval(this.refreshTimer); - this.refreshTimer = null; + if (!responseBody.success || !responseBody.data) { + logger.error('[ItemManager] API returned unexpected payload structure'); + return; } - // Initial fetch - await this.init(); + const items: IItemJSON[] = responseBody.data; - // Schedule recurring refreshes - this.refreshTimer = setInterval(() => this.init(), REFRESH_INTERVAL); - } - - /** - * Stop the auto-refresh loop. Call during shutdown. - */ - public static shutdown(): void { - if (this.refreshTimer) { - clearInterval(this.refreshTimer); - this.refreshTimer = null; + // Atomic swap: build the new cache fully before replacing the old one. + // This ensures any command reading mid-refresh still gets complete data. + const newCache = new Collection(); + for (const item of items) { + newCache.set(item.itemId, item); } - } - /** - * Retrieve a single item by ID. Returns undefined if not found. - */ - public static get(itemId: number): IItemJSON | undefined { - if (!this.isLoaded) { - logger.warn(`[ItemManager] Attempted to get item ${itemId} before cache was loaded`); + cache = newCache; + isLoaded = true; + + logger.info(`[ItemManager] Synced ${newCache.size} items`); + } catch (error: any) { + // Distinguish timeout from other errors for clearer debugging + if (error.name === 'TimeoutError' || error.name === 'AbortError') { + logger.error( + `[ItemManager] Fetch timed out after ${FETCH_TIMEOUT / 1000}s` + ); + } else { + logger.error(error, '[ItemManager] Critical fetch error:'); } - return this.cache.get(itemId); + } finally { + isRefreshing = false; + } +} + +/** + * Start the auto-refresh loop. Safe to call multiple times โ€” + * clears any existing interval before creating a new one. + */ +export async function refresh(): Promise { + // Clear any existing interval to prevent stacking + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; } - /** - * Number of items currently cached. - */ - public static get size(): number { - return this.cache.size; + // Initial fetch + await init(); + + // Schedule recurring refreshes + refreshTimer = setInterval(() => init(), REFRESH_INTERVAL); +} + +/** + * Stop the auto-refresh loop. Call during shutdown. + */ +export function shutdown(): void { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } +} + +/** + * Retrieve a single item by ID. Returns undefined if not found. + */ +export function get(itemId: number): IItemJSON | undefined { + if (!isLoaded) { + logger.warn( + `[ItemManager] Attempted to get item ${itemId} before cache was loaded` + ); } -} \ No newline at end of file + return cache.get(itemId); +} + +/** + * Number of items currently cached. + */ +export function size(): number { + return cache.size; +} diff --git a/src/managers/PresenceManager.ts b/src/managers/PresenceManager.ts index 0a617f6..ae67e96 100644 --- a/src/managers/PresenceManager.ts +++ b/src/managers/PresenceManager.ts @@ -1,7 +1,6 @@ -import { ActivityType, Client } from 'discord.js'; +import { ActivityType, type Client } from 'discord.js'; import logger from '../utilities/Logger'; -import Routes from '../utilities/Routes'; -import ItemManager from './ItemManager'; +import * as Routes from '../utilities/Routes'; import { apiFetch } from '../utilities/ApiClient'; interface GameStats { @@ -13,90 +12,107 @@ interface GameStats { const ROTATION_INTERVAL = 60_000; const FETCH_INTERVAL = 300_000; -export default class PresenceManager { - private static client: Client; - private static rotationIndex: number = 0; - private static rotationTimer: NodeJS.Timeout | null = null; - private static fetchTimer: NodeJS.Timeout | null = null; - private static totalGuilds: number = 0; +let client: Client; +let rotationIndex: number = 0; +let rotationTimer: NodeJS.Timeout | null = null; +let fetchTimer: NodeJS.Timeout | null = null; +let totalGuilds: number = 0; - private static stats: GameStats = { - players: 0, - items: 0, - scenarios: 0, - }; +const stats: GameStats = { + players: 0, + items: 0, + scenarios: 0 +}; - public static async init(client: Client): Promise { - this.client = client; +export async function init(newClient: Client): Promise { + client = newClient; - await this.fetchStats(); - this.fetchTimer = setInterval(() => this.fetchStats(), FETCH_INTERVAL); + await fetchStats(); + fetchTimer = setInterval(() => fetchStats(), FETCH_INTERVAL); - this.rotate(); - this.rotationTimer = setInterval(() => this.rotate(), ROTATION_INTERVAL); + rotate(); + rotationTimer = setInterval(() => rotate(), ROTATION_INTERVAL); - logger.info('[PresenceManager] Activity rotation started'); - } + logger.info('[PresenceManager] Activity rotation started'); +} - private static async fetchStats(): Promise { - try { - const telemetryRes = await apiFetch('https://capi.gg/api/telemetry/db-stats'); +async function fetchStats(): Promise { + try { + const telemetryRes = await apiFetch( + 'https://capi.gg/api/telemetry/db-stats' + ); - if (telemetryRes.ok) { - const data = await telemetryRes.json(); - this.stats.players = data.players ?? this.stats.players; - this.stats.items = data.items ?? this.stats.items; - } + if (telemetryRes.ok) { + const data = await telemetryRes.json(); + stats.players = data.players ?? stats.players; + stats.items = data.items ?? stats.items; + } - const scenarioRes = await apiFetch(Routes.scenarios()); + const scenarioRes = await apiFetch(Routes.scenarios()); - if (scenarioRes.ok) { - const data = await scenarioRes.json(); - this.stats.scenarios = data.count ?? this.stats.scenarios; - } + if (scenarioRes.ok) { + const data = await scenarioRes.json(); + stats.scenarios = data.count ?? stats.scenarios; + } - // Fetch total guild count across all clusters - const cluster = (this.client as any).cluster; - if (cluster) { - try { - const results = await cluster.broadcastEval((c: any) => c.guilds.cache.size); - this.totalGuilds = results.reduce((acc: number, val: number) => acc + val, 0); - } catch { - this.totalGuilds = this.client.guilds.cache.size; - } - } else { - this.totalGuilds = this.client.guilds.cache.size; + // Fetch total guild count across all clusters + const cluster = (client! as any).cluster; + if (cluster) { + try { + const results = await cluster.broadcastEval( + (c: any) => c.guilds.cache.size + ); + totalGuilds = results.reduce( + (acc: number, val: number) => acc + val, + 0 + ); + } catch { + totalGuilds = client!.guilds.cache.size; } - } catch (err) { - logger.warn(`[PresenceManager] Failed to fetch stats: ${err}`); + } else { + totalGuilds = client!.guilds.cache.size; } + } catch (err) { + logger.warn(`[PresenceManager] Failed to fetch stats: ${err}`); } +} - private static rotate(): void { - if (!this.client.user) return; - - const activities = [ - { type: ActivityType.Watching, name: `${this.stats.players.toLocaleString()} players` }, - { type: ActivityType.Watching, name: `${this.totalGuilds.toLocaleString()} servers` }, - { type: ActivityType.Watching, name: `${this.stats.items.toLocaleString()} items` }, - { type: ActivityType.Watching, name: `${this.stats.scenarios.toLocaleString()} scenarios` }, - { type: ActivityType.Playing, name: `capi.gg` }, - ]; - - const current = activities[this.rotationIndex % activities.length]; - - this.client.user.setPresence({ - activities: [{ name: current.name, type: current.type }], - status: 'online', - }); - - this.rotationIndex++; - } +function rotate(): void { + if (!client!.user) return; + + const activities = [ + { + type: ActivityType.Watching, + name: `${stats.players.toLocaleString()} players` + }, + { + type: ActivityType.Watching, + name: `${totalGuilds.toLocaleString()} servers` + }, + { + type: ActivityType.Watching, + name: `${stats.items.toLocaleString()} items` + }, + { + type: ActivityType.Watching, + name: `${stats.scenarios.toLocaleString()} scenarios` + }, + { type: ActivityType.Playing, name: `capi.gg` } + ]; + + const current = activities[rotationIndex % activities.length]; + + client!.user.setPresence({ + activities: [{ name: current.name, type: current.type }], + status: 'online' + }); + + rotationIndex++; +} - public static shutdown(): void { - if (this.rotationTimer) clearInterval(this.rotationTimer); - if (this.fetchTimer) clearInterval(this.fetchTimer); - this.rotationTimer = null; - this.fetchTimer = null; - } +export function shutdown(): void { + if (rotationTimer) clearInterval(rotationTimer); + if (fetchTimer) clearInterval(fetchTimer); + rotationTimer = null; + fetchTimer = null; } diff --git a/src/structures/Button.ts b/src/structures/Button.ts index bfdb262..119095c 100644 --- a/src/structures/Button.ts +++ b/src/structures/Button.ts @@ -1,17 +1,34 @@ -import IExecutable from "../interfaces/IExecutable"; -import ICooldown from "../interfaces/ICooldown"; -import { ButtonInteraction, Client } from "discord.js"; +import type IExecutable from '../interfaces/IExecutable'; +import type { ButtonInteraction, Client } from 'discord.js'; -export default abstract class Button implements IExecutable, ICooldown { - public customId: string; +export interface ButtonOptions { + customId: string; + cooldown: number; + isAuthorOnly: boolean; +} - constructor(customId: string) { - this.customId = customId; +export default abstract class Button implements IExecutable { + private readonly options: ButtonOptions; + + constructor(options: ButtonOptions) { + this.options = options; } - public abstract isAuthorOnly(): boolean; + public get customId(): string { + return this.options.customId; + } - public abstract execute(interaction: ButtonInteraction, client: Client, args?: string[] | null): Promise; + public get cooldown(): number { + return this.options.cooldown; + } + + public get isAuthorOnly(): boolean { + return this.options.isAuthorOnly; + } - public abstract cooldown(): number; -} \ No newline at end of file + public abstract execute( + interaction: ButtonInteraction, + client: Client, + args?: string[] | null + ): Promise; +} diff --git a/src/structures/Event.ts b/src/structures/Event.ts index 923870d..e3e2bd7 100644 --- a/src/structures/Event.ts +++ b/src/structures/Event.ts @@ -1,17 +1,24 @@ -import IExecutable from "../interfaces/IExecutable"; +import type IExecutable from '../interfaces/IExecutable'; + +export interface EventOptions { + name: string; + isOnce: boolean; +} export default abstract class Event implements IExecutable { - protected name: string; + private readonly options: EventOptions; - constructor(name: string) { - this.name = name; + constructor(options: EventOptions) { + this.options = options; } - public abstract execute(...args: any[]): Promise; - - public abstract isOnce(): boolean; + public get name(): string { + return this.options.name; + } - public getName(): string { - return this.name; + public get isOnce(): boolean { + return this.options.isOnce; } -} \ No newline at end of file + + public abstract execute(...args: any[]): Promise; +} diff --git a/src/structures/ModalSubmit.ts b/src/structures/ModalSubmit.ts index 120a3e5..016dfa8 100644 --- a/src/structures/ModalSubmit.ts +++ b/src/structures/ModalSubmit.ts @@ -1,17 +1,34 @@ -import { ModalSubmitInteraction, Client } from "discord.js"; -import IExecutable from "../interfaces/IExecutable"; -import ICooldown from "../interfaces/ICooldown"; +import type { ModalSubmitInteraction, Client } from 'discord.js'; +import type IExecutable from '../interfaces/IExecutable'; -export default abstract class ModalSubmit implements IExecutable, ICooldown { - public customId: string; +export interface ModalSubmitOptions { + customId: string; + cooldown: number; + isAuthorOnly: boolean; +} - constructor(customId: string) { - this.customId = customId; +export default abstract class ModalSubmit implements IExecutable { + private readonly options: ModalSubmitOptions; + + constructor(options: ModalSubmitOptions) { + this.options = options; } - public abstract execute(interaction: ModalSubmitInteraction, client: Client, args?: string[] | null): Promise; + public get customId(): string { + return this.options.customId; + } - public abstract isAuthorOnly(): boolean; + public get cooldown(): number { + return this.options.cooldown; + } + + public get isAuthorOnly(): boolean { + return this.options.isAuthorOnly; + } - public abstract cooldown(): number; -} \ No newline at end of file + public abstract execute( + interaction: ModalSubmitInteraction, + client: Client, + args?: string[] | null + ): Promise; +} diff --git a/src/structures/SelectMenu.ts b/src/structures/SelectMenu.ts index 1da62cc..9b72fd5 100644 --- a/src/structures/SelectMenu.ts +++ b/src/structures/SelectMenu.ts @@ -1,17 +1,34 @@ -import { AnySelectMenuInteraction, Client } from "discord.js"; -import IExecutable from "../interfaces/IExecutable"; -import ICooldown from "../interfaces/ICooldown"; +import type { AnySelectMenuInteraction, Client } from 'discord.js'; +import type IExecutable from '../interfaces/IExecutable'; -export default abstract class SelectMenu implements IExecutable, ICooldown { - public customId: string; +export interface SelectMenuOptions { + customId: string; + cooldown: number; + isAuthorOnly: boolean; +} - constructor(customId: string) { - this.customId = customId; +export default abstract class SelectMenu implements IExecutable { + private readonly options: SelectMenuOptions; + + constructor(options: SelectMenuOptions) { + this.options = options; } - public abstract isAuthorOnly(): boolean; + public get customId(): string { + return this.options.customId; + } - public abstract execute(interaction: AnySelectMenuInteraction, client: Client, args?: string[] | null): Promise; + public get cooldown(): number { + return this.options.cooldown; + } + + public get isAuthorOnly(): boolean { + return this.options.isAuthorOnly; + } - public abstract cooldown(): number; -} \ No newline at end of file + public abstract execute( + interaction: AnySelectMenuInteraction, + client: Client, + args?: string[] | null + ): Promise; +} diff --git a/src/structures/SlashCommand.ts b/src/structures/SlashCommand.ts index a5012d1..11009a4 100644 --- a/src/structures/SlashCommand.ts +++ b/src/structures/SlashCommand.ts @@ -1,44 +1,61 @@ -import { AutocompleteInteraction, ChatInputCommandInteraction, SlashCommandBuilder } from "discord.js"; -import IExecutable from "../interfaces/IExecutable"; -import ICooldown from "../interfaces/ICooldown"; -import { Client } from "discord.js"; - -export default abstract class SlashCommand implements IExecutable, ICooldown { - protected name: string; - protected description: string; - protected category: string; - protected data: SlashCommandBuilder; - - constructor(name: string, description: string, category: string) { - this.name = name; - this.description = description; - this.category = category; - this.data = new SlashCommandBuilder() - .setName(name) - .setDescription(description); +import { + type AutocompleteInteraction, + type ChatInputCommandInteraction, + SlashCommandBuilder +} from 'discord.js'; +import type IExecutable from '../interfaces/IExecutable'; +import { type Client } from 'discord.js'; + +export interface SlashCommandOptions { + name: string; + description: string; + category: string; + cooldown: number; + isGlobalCommand: boolean; +} + +export default abstract class SlashCommand implements IExecutable { + private readonly options: SlashCommandOptions; + protected builder: SlashCommandBuilder; + + constructor(options: SlashCommandOptions) { + this.options = options; + this.builder = new SlashCommandBuilder() + .setName(options.name) + .setDescription(options.description); } - public abstract execute(interaction: ChatInputCommandInteraction, client: Client): Promise; - - public abstract cooldown(): number; - - public abstract isGlobalCommand(): boolean; + public get name(): string { + return this.options.name; + } - public async autocomplete?(interaction: AutocompleteInteraction, client: Client): Promise; + public get description(): string { + return this.options.description; + } - public getData(): SlashCommandBuilder { - return this.data; + public get category(): string { + return this.options.category; } - public getName(): string { - return this.name; + public get cooldown(): number { + return this.options.cooldown; } - public getDescription(): string { - return this.description; + public get isGlobalCommand(): boolean { + return this.options.isGlobalCommand; } - public getCategory(): string { - return this.category; + public get data(): SlashCommandBuilder { + return this.builder; } -} \ No newline at end of file + + public abstract execute( + interaction: ChatInputCommandInteraction, + client: Client + ): Promise; + + public async autocomplete?( + interaction: AutocompleteInteraction, + client: Client + ): Promise; +} diff --git a/src/structures/containers/AttackContainer.ts b/src/structures/containers/AttackContainer.ts index 164d23d..f4588b8 100644 --- a/src/structures/containers/AttackContainer.ts +++ b/src/structures/containers/AttackContainer.ts @@ -1,5 +1,5 @@ -import { ContainerBuilder } from "discord.js"; -import { ICombatJSON } from "../../interfaces/ICombatJSON"; +import { ContainerBuilder } from 'discord.js'; +import { type ICombatJSON } from '../../interfaces/ICombatJSON'; export default class AttackContainer { private data: ICombatJSON; @@ -11,50 +11,53 @@ export default class AttackContainer { public build(): ContainerBuilder { const container = new ContainerBuilder(); - container.setAccentColor(this.data.victory ? 0x10b981 : (this.data.combatEnded ? 0x6b7280 : 0xef4444)); + container.setAccentColor( + this.data.victory ? 0x10b981 : this.data.combatEnded ? 0x6b7280 : 0xef4444 + ); - const cleanFlavorText = this.data.flavorText.replace(/\[([^\]]+)\]\(color:#[0-9a-fA-F]+\)/g, '**$1**'); + const cleanFlavorText = this.data.flavorText.replace( + /\[([^\]]+)\]\(color:#[0-9a-fA-F]+\)/g, + '**$1**' + ); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(cleanFlavorText) + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(cleanFlavorText) ); if (!this.data.combatEnded && this.data.enemy) { container.addSeparatorComponents((s) => s); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`**Your HP:** โค๏ธ \`${this.data.playerStats.stats.hp.toLocaleString()}/${this.data.playerStats.maxHp?.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**${this.data.enemy!.name}'s HP:** โค๏ธ \`${Math.max(0, this.data.enemy!.currentHp)}/${this.data.enemy!.maxHp}\``), - (textDisplay) => - textDisplay.setContent(`-# Use /attack to strike again!`) + (textDisplay) => textDisplay.setContent( + `**Your HP:** โค๏ธ \`${this.data.playerStats.stats.hp.toLocaleString()}/${this.data.playerStats.maxHp?.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**${this.data.enemy!.name}'s HP:** โค๏ธ \`${Math.max(0, this.data.enemy!.currentHp)}/${this.data.enemy!.maxHp}\`` + ), + (textDisplay) => textDisplay.setContent(`-# Use /attack to strike again!`) ); } if (this.data.victory && this.data.rewards) { container.addSeparatorComponents((s) => s); - let rewardText = []; - if (this.data.rewards.xp) rewardText.push(`โœจ +${this.data.rewards.xp.toLocaleString()} XP`); - if (this.data.rewards.gold) rewardText.push(`๐Ÿช™ +${this.data.rewards.gold.toLocaleString()} Gold`); - if (this.data.rewards.item) rewardText.push(`๐ŸŽ’ Looted: **${this.data.rewards.item.name}**`); + const rewardText = []; + if (this.data.rewards.xp) + rewardText.push(`โœจ +${this.data.rewards.xp.toLocaleString()} XP`); + if (this.data.rewards.gold) + rewardText.push(`๐Ÿช™ +${this.data.rewards.gold.toLocaleString()} Gold`); + if (this.data.rewards.item) + rewardText.push(`๐ŸŽ’ Looted: **${this.data.rewards.item.name}**`); for (const reward of rewardText) { - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(reward) + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(reward) ); } } container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; } -} \ No newline at end of file +} diff --git a/src/structures/containers/ExploreContainer.ts b/src/structures/containers/ExploreContainer.ts index a192ae0..24f0428 100644 --- a/src/structures/containers/ExploreContainer.ts +++ b/src/structures/containers/ExploreContainer.ts @@ -1,5 +1,5 @@ -import { ContainerBuilder } from "discord.js"; -import { IStepJSON } from "../../interfaces/IStepJSON"; +import { ContainerBuilder } from 'discord.js'; +import { type IStepJSON } from '../../interfaces/IStepJSON'; export default class ExploreContainer { private data: IStepJSON; @@ -11,38 +11,40 @@ export default class ExploreContainer { public build(): ContainerBuilder { const container = new ContainerBuilder(); - container.setAccentColor(this.data.combatTrigger || this.data.inCombat ? 0xef4444 : 0x3b82f6); + container.setAccentColor( + this.data.combatTrigger || this.data.inCombat ? 0xef4444 : 0x3b82f6 + ); - const cleanFlavorText = this.data.flavorText.replace(/\[([^\]]+)\]\(color:#[0-9a-fA-F]+\)/g, '**$1**'); + const cleanFlavorText = this.data.flavorText.replace( + /\[([^\]]+)\]\(color:#[0-9a-fA-F]+\)/g, + '**$1**' + ); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(cleanFlavorText), - (textDisplay) => - textDisplay.setContent(`-# **ID:** \`${this.data.scenarioId}\` | **Author:** \`${this.data.scenarioAuthor}\``) + (textDisplay) => textDisplay.setContent(cleanFlavorText), + (textDisplay) => textDisplay.setContent( + `-# **ID:** \`${this.data.scenarioId}\` | **Author:** \`${this.data.scenarioAuthor}\`` + ) ); if (this.data.enemy) { const enemy = this.data.enemy; container.addSeparatorComponents((s) => s); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`**Enemy**: \`LVL${enemy.level.toLocaleString()} ${enemy.name}\``), - (textDisplay) => - textDisplay.setContent(`**HP:** \`${enemy.currentHp.toLocaleString()}/${enemy.maxHp.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**ATK:** \`${enemy.atk.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**DEF:** \`${enemy.def.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`-# Use the /attack command to fight`) + (textDisplay) => textDisplay.setContent( + `**Enemy**: \`LVL${enemy.level.toLocaleString()} ${enemy.name}\`` + ), + (textDisplay) => textDisplay.setContent( + `**HP:** \`${enemy.currentHp.toLocaleString()}/${enemy.maxHp.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent(`**ATK:** \`${enemy.atk.toLocaleString()}\``), + (textDisplay) => textDisplay.setContent(`**DEF:** \`${enemy.def.toLocaleString()}\``), + (textDisplay) => textDisplay.setContent(`-# Use the /attack command to fight`) ); container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; @@ -52,32 +54,34 @@ export default class ExploreContainer { const experience = stats.exp ?? 0; const expRequired = stats.expRequired ?? 1; - let rewardText = []; - if (this.data.rewards.xp) rewardText.push(`โœจ +${this.data.rewards.xp} XP`); - if (this.data.rewards.gold) rewardText.push(`๐Ÿช™ +${this.data.rewards.gold} Gold`); - if (this.data.rewards.item) rewardText.push(`๐ŸŽ’ Found: **${this.data.rewards.item.name}** (${this.data.rewards.item.rarity})`); - if (this.data.rewards.levelsGained > 0) rewardText.push('๐Ÿ†™ **LEVEL UP!**'); + const rewardText = []; + if (this.data.rewards.xp) + rewardText.push(`โœจ +${this.data.rewards.xp} XP`); + if (this.data.rewards.gold) + rewardText.push(`๐Ÿช™ +${this.data.rewards.gold} Gold`); + if (this.data.rewards.item) + rewardText.push( + `๐ŸŽ’ Found: **${this.data.rewards.item.name}** (${this.data.rewards.item.rarity})` + ); + if (this.data.rewards.levelsGained > 0) + rewardText.push('๐Ÿ†™ **LEVEL UP!**'); if (rewardText.length >= 1) { container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`-# **Lvl:** \`${level.toLocaleString()}\` | **Exp:** \`${experience.toLocaleString()}/${expRequired.toLocaleString()}\``) + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent( + `-# **Lvl:** \`${level.toLocaleString()}\` | **Exp:** \`${experience.toLocaleString()}/${expRequired.toLocaleString()}\`` ) + ); } for (const text of rewardText) { - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(text) + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(text) ); } container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; @@ -85,11 +89,9 @@ export default class ExploreContainer { container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; } -} \ No newline at end of file +} diff --git a/src/structures/containers/ItemLookupContainer.ts b/src/structures/containers/ItemLookupContainer.ts index f5c90c2..682162b 100644 --- a/src/structures/containers/ItemLookupContainer.ts +++ b/src/structures/containers/ItemLookupContainer.ts @@ -1,5 +1,5 @@ -import { ContainerBuilder } from "discord.js"; -import { IItemJSON, RARITY_COLORS } from "../../interfaces/IItemJSON"; +import { ContainerBuilder } from 'discord.js'; +import { type IItemJSON, RARITY_COLORS } from '../../interfaces/IItemJSON'; export default class ItemLookupContainer { private data: IItemJSON; @@ -9,35 +9,35 @@ export default class ItemLookupContainer { } public build(): ContainerBuilder { - const container = new ContainerBuilder().setAccentColor(RARITY_COLORS[this.data.rarity]); + const container = new ContainerBuilder().setAccentColor( + RARITY_COLORS[this.data.rarity] + ); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`## LVL${this.data.level} ${this.data.name}`), - (textDisplay) => - textDisplay.setContent(`-# *${this.data.rarity} ${this.data.slot === 'None' ? '' : this.data.slot} ${this.data.type}*`), - (textDisplay) => - textDisplay.setContent(`*${this.data.description}*`), - (textDisplay) => - textDisplay.setContent(`-# **Stats:**\n**ATK:** \`${this.data.stats.atk.toLocaleString()}\`, **DEF:** \`${this.data.stats.def.toLocaleString()}\`, **HP:** \`${this.data.stats.hp.toLocaleString()}\``) + (textDisplay) => textDisplay.setContent(`## LVL${this.data.level} ${this.data.name}`), + (textDisplay) => textDisplay.setContent( + `-# *${this.data.rarity} ${this.data.slot === 'None' ? '' : this.data.slot} ${this.data.type}*` + ), + (textDisplay) => textDisplay.setContent(`*${this.data.description}*`), + (textDisplay) => textDisplay.setContent( + `-# **Stats:**\n**ATK:** \`${this.data.stats.atk.toLocaleString()}\`, **DEF:** \`${this.data.stats.def.toLocaleString()}\`, **HP:** \`${this.data.stats.hp.toLocaleString()}\`` + ) ); if (this.data.affixes) { for (const affix of this.data.affixes) { - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`**${affix.type}** \`${affix.value}${affix.type === 'THORNS' ? '' : '%'}\``) + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent( + `**${affix.type}** \`${affix.value}${affix.type === 'THORNS' ? '' : '%'}\`` + ) ); } } container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; } -} \ No newline at end of file +} diff --git a/src/structures/containers/NPCLookupContainer.ts b/src/structures/containers/NPCLookupContainer.ts index 6303f6a..6fbd9fa 100644 --- a/src/structures/containers/NPCLookupContainer.ts +++ b/src/structures/containers/NPCLookupContainer.ts @@ -1,5 +1,5 @@ -import { ContainerBuilder } from "discord.js"; -import { INPCJSON } from "../../interfaces/INPCJSON"; +import { ContainerBuilder } from 'discord.js'; +import { type INPCJSON } from '../../interfaces/INPCJSON'; export default class NPCLookupContainer { private data: INPCJSON; @@ -12,12 +12,10 @@ export default class NPCLookupContainer { const container = new ContainerBuilder(); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`## ๐Ÿ’€ (ID: ${this.data.id}) ${this.data.name}`), - (textDisplay) => - textDisplay.setContent(this.data.description) + (textDisplay) => textDisplay.setContent(`## ๐Ÿ’€ (ID: ${this.data.id}) ${this.data.name}`), + (textDisplay) => textDisplay.setContent(this.data.description) ); return container; } -} \ No newline at end of file +} diff --git a/src/structures/containers/ProfileContainer.ts b/src/structures/containers/ProfileContainer.ts index 727a6bb..19e4a13 100644 --- a/src/structures/containers/ProfileContainer.ts +++ b/src/structures/containers/ProfileContainer.ts @@ -1,5 +1,5 @@ -import { ContainerBuilder } from "discord.js"; -import { IPlayerJSON } from "../../interfaces/IPlayerJSON"; +import { ContainerBuilder } from 'discord.js'; +import { type IPlayerJSON } from '../../interfaces/IPlayerJSON'; export default class ProfileContainer { private data: IPlayerJSON; @@ -11,56 +11,63 @@ export default class ProfileContainer { public build(): ContainerBuilder { const container = new ContainerBuilder(); - container.addSectionComponents( - (section) => - section.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`**Username:** \`${this.data.username}\``), - (textDisplay) => - textDisplay.setContent(`**Level:** \`${this.data.level.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**Experience:** \`${this.data.experience.toLocaleString()}\``) - ).setThumbnailAccessory( - (tb) => - tb.setURL(`https://cdn.discordapp.com/avatars/${this.data.id}/${this.data.avatar}.png`) + container.addSectionComponents((section) => section + .addTextDisplayComponents( + (textDisplay) => textDisplay.setContent(`**Username:** \`${this.data.username}\``), + (textDisplay) => textDisplay.setContent( + `**Level:** \`${this.data.level.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**Experience:** \`${this.data.experience.toLocaleString()}\`` ) + ) + .setThumbnailAccessory((tb) => tb.setURL( + `https://cdn.discordapp.com/avatars/${this.data.id}/${this.data.avatar}.png` + ) + ) ); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`**Privilege:** \`${this.data.privilege}\``), - (textDisplay) => - textDisplay.setContent(`**Coins:** \`${this.data.coins.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**HP:** \`${this.data.stats.hp}/${this.data.maxHp ?? 0}\``), - (textDisplay) => - textDisplay.setContent(`**ATK:** \`${this.data.stats.atk.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**DEF:** \`${this.data.stats.def.toLocaleString()}\``), + (textDisplay) => textDisplay.setContent(`**Privilege:** \`${this.data.privilege}\``), + (textDisplay) => textDisplay.setContent( + `**Coins:** \`${this.data.coins.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**HP:** \`${this.data.stats.hp}/${this.data.maxHp ?? 0}\`` + ), + (textDisplay) => textDisplay.setContent( + `**ATK:** \`${this.data.stats.atk.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**DEF:** \`${this.data.stats.def.toLocaleString()}\`` + ) ); container.addSeparatorComponents((separator) => separator); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent(`**Days Passed:** \`${this.data.statistics.daysPassed.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**Enemies Defeated:** \`${this.data.statistics.enemiesDefeated.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**Players Defeated:** \`${this.data.statistics.playersDefeated.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**Times Died:** \`${this.data.statistics.timesDied.toLocaleString()}\``), - (textDisplay) => - textDisplay.setContent(`**Quests Done:** \`${this.data.statistics.questsDone.toLocaleString()}\``) + (textDisplay) => textDisplay.setContent( + `**Days Passed:** \`${this.data.statistics.daysPassed.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**Enemies Defeated:** \`${this.data.statistics.enemiesDefeated.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**Players Defeated:** \`${this.data.statistics.playersDefeated.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**Times Died:** \`${this.data.statistics.timesDied.toLocaleString()}\`` + ), + (textDisplay) => textDisplay.setContent( + `**Quests Done:** \`${this.data.statistics.questsDone.toLocaleString()}\`` + ) ); container.addSeparatorComponents((separator) => separator); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; } -} \ No newline at end of file +} diff --git a/src/structures/containers/ScenarioLookupContainer.ts b/src/structures/containers/ScenarioLookupContainer.ts index 1dd8e4b..3b9abb8 100644 --- a/src/structures/containers/ScenarioLookupContainer.ts +++ b/src/structures/containers/ScenarioLookupContainer.ts @@ -1,5 +1,5 @@ -import { ContainerBuilder } from "discord.js"; -import { IScenarioJSON } from "../../interfaces/IScenarioJSON"; +import { ContainerBuilder } from 'discord.js'; +import { type IScenarioJSON } from '../../interfaces/IScenarioJSON'; export default class ScenarioLookupContainer { private data: IScenarioJSON; @@ -12,23 +12,21 @@ export default class ScenarioLookupContainer { const container = new ContainerBuilder(); container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('## Scenario Viewer'), - (textDisplay) => - textDisplay.setContent(this.data.description), - (textDisplay) => - textDisplay.setContent(`-# **ID:** \`${this.data.id}\` | **Created By:** \`${this.data.createdBy}\``), - (textDisplay) => - textDisplay.setContent(`-# **Created On:** \`${new Date(this.data.createdOn).toDateString()}\` | **Last Updated:** \`${new Date(this.data.lastUpdated).toDateString()}\``) + (textDisplay) => textDisplay.setContent('## Scenario Viewer'), + (textDisplay) => textDisplay.setContent(this.data.description), + (textDisplay) => textDisplay.setContent( + `-# **ID:** \`${this.data.id}\` | **Created By:** \`${this.data.createdBy}\`` + ), + (textDisplay) => textDisplay.setContent( + `-# **Created On:** \`${new Date(this.data.createdOn).toDateString()}\` | **Last Updated:** \`${new Date(this.data.lastUpdated).toDateString()}\`` + ) ); container.addSeparatorComponents((s) => s); - container.addTextDisplayComponents( - (textDisplay) => - textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') + container.addTextDisplayComponents((textDisplay) => textDisplay.setContent('-# โš”๏ธ DFO Cross-Platform Integration') ); return container; } -} \ No newline at end of file +} diff --git a/src/utilities/AdventureImageBuilder.ts b/src/utilities/AdventureImageBuilder.ts index 1f54413..8a0e963 100644 --- a/src/utilities/AdventureImageBuilder.ts +++ b/src/utilities/AdventureImageBuilder.ts @@ -1,472 +1,696 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; -import { IStepJSON } from '../interfaces/IStepJSON'; -import { ICombatJSON } from '../interfaces/ICombatJSON'; +import { type IStepJSON } from '../interfaces/IStepJSON'; +import { type ICombatJSON } from '../interfaces/ICombatJSON'; import { join } from 'path'; // Load OS-agnostic emoji font -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} - -export default class AdventureImageBuilder { - - // Fully Upgraded Discord Markdown & Layout Engine - private static processText(ctx: any, text: string, startX: number, startY: number, maxWidth: number, baseLineHeight: number, defaultColor: string, draw: boolean): number { - const lines = text.split('\n'); - let currentY = startY; - - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - - // 1. Block-Level Modifiers (Headers, Quotes, Lists, Subtext) - let isQuote = false; - let isSubtext = false; - let headerLevel = 0; - let listPrefix = ''; - let lineIndent = 0; - - if (line.startsWith('>>> ')) { isQuote = true; line = line.substring(4); } - else if (line.startsWith('> ')) { isQuote = true; line = line.substring(2); } - - if (line.startsWith('-# ')) { isSubtext = true; line = line.substring(3); } - else if (line.startsWith('### ')) { headerLevel = 3; line = line.substring(4); } - else if (line.startsWith('## ')) { headerLevel = 2; line = line.substring(3); } - else if (line.startsWith('# ')) { headerLevel = 1; line = line.substring(2); } - - const listMatch = line.match(/^(\s*[-*+]\s|\s*\d+\.\s)/); - if (listMatch) { - listPrefix = listMatch[0].trim(); - line = line.substring(listMatch[0].length); - lineIndent = 25; // Push list items inward - } +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} + +// Fully Upgraded Discord Markdown & Layout Engine +function processText( + ctx: any, + text: string, + startX: number, + startY: number, + maxWidth: number, + baseLineHeight: number, + defaultColor: string, + draw: boolean +): number { + const lines = text.split('\n'); + let currentY = startY; + + for (let line of lines) { + // 1. Block-Level Modifiers (Headers, Quotes, Lists, Subtext) + let isQuote = false; + let isSubtext = false; + let headerLevel = 0; + let listPrefix = ''; + let lineIndent = 0; + + if (line.startsWith('>>> ')) { + isQuote = true; + line = line.substring(4); + } else if (line.startsWith('> ')) { + isQuote = true; + line = line.substring(2); + } - if (isQuote) lineIndent = 20; - - // 2. Inline Markdown to Pseudo-HTML conversion - let parsedLine = line - .replace(/\|\|([^|]+)\|\|/g, '$1') // Strip Spoilers - .replace(/<@!?\d+>/g, '@User') // Format Mentions - .replace(/\*\*\*(.+?)\*\*\*/g, '$1') // Bold Italic - .replace(/\*\*(.+?)\*\*/g, '$1') // Bold - .replace(/\*(.+?)\*/g, '$1') // Italic - .replace(/__(.+?)__/g, '$1') // Underline - .replace(/~~(.+?)~~/g, '$1') // Strikethrough - .replace(/`([^`]+)`/g, '$1') // Inline Code - .replace(/\[([^\]]+)\]\(color:(#[0-9a-fA-F]{3,6})\)/g, '$1'); // Hex Colors - - // 3. Tokenize by our custom tags - const tokens = parsedLine.split(/(|<\/bi>||<\/b>||<\/i>||<\/u>||<\/s>||<\/c>||<\/col>)/g).filter(Boolean); - - let currentState = { - bold: headerLevel > 0, - italic: isQuote || isSubtext, - underline: false, - strike: false, - code: false, - color: isSubtext ? '#6b7280' : (isQuote ? '#9ca3af' : defaultColor) - }; - - const wordObjects: { word: string, state: any }[] = []; - - // Inject the bullet point/number if it's a list - if (listPrefix) { - wordObjects.push({ word: listPrefix + ' ', state: { ...currentState, color: '#10b981', bold: true } }); - } + if (line.startsWith('-# ')) { + isSubtext = true; + line = line.substring(3); + } else if (line.startsWith('### ')) { + headerLevel = 3; + line = line.substring(4); + } else if (line.startsWith('## ')) { + headerLevel = 2; + line = line.substring(3); + } else if (line.startsWith('# ')) { + headerLevel = 1; + line = line.substring(2); + } - // Apply state toggles and chunk text into words - for (const token of tokens) { - if (token === '') { currentState.bold = true; currentState.italic = true; } - else if (token === '') { currentState.bold = false; currentState.italic = false; } - else if (token === '') currentState.bold = true; - else if (token === '') currentState.bold = false; - else if (token === '') currentState.italic = true; - else if (token === '') currentState.italic = false; - else if (token === '') currentState.underline = true; - else if (token === '') currentState.underline = false; - else if (token === '') currentState.strike = true; - else if (token === '') currentState.strike = false; - else if (token === '') { currentState.code = true; currentState.color = '#6ee7b7'; } - else if (token === '') { currentState.code = false; currentState.color = isSubtext ? '#6b7280' : (isQuote ? '#9ca3af' : defaultColor); } - else if (token.startsWith('') { currentState.color = isSubtext ? '#6b7280' : (isQuote ? '#9ca3af' : defaultColor); } - else { - const textWords = token.split(' '); - for (let w = 0; w < textWords.length; w++) { - const wordStr = textWords[w] + (w < textWords.length - 1 ? ' ' : ''); - if (wordStr.length > 0) { - wordObjects.push({ word: wordStr, state: { ...currentState } }); - } - } - } - } + const listMatch = line.match(/^(\s*[-*+]\s|\s*\d+\.\s)/); + if (listMatch) { + listPrefix = listMatch[0].trim(); + line = line.substring(listMatch[0].length); + lineIndent = 25; // Push list items inward + } - const getFont = (state: any) => { - let weight = state.bold ? 'bold ' : ''; - let style = state.italic ? 'italic ' : ''; - let size = 22; // Base size - - if (headerLevel === 1) size = 32; - else if (headerLevel === 2) size = 28; - else if (headerLevel === 3) size = 24; - else if (isSubtext) size = 16; - else if (state.code) size = 20; - - let family = state.code ? 'monospace' : 'sans-serif'; - if (!state.code && !headerLevel && !isSubtext) family = 'monospace'; - - return `${style}${weight}${size}px ${family}`; - }; - - let lineWords: any[] = []; - let currentLineWidth = 0; - const startYOfParagraph = currentY; - - let increment = baseLineHeight; - if (headerLevel === 1) increment = 44; - else if (headerLevel === 2) increment = 38; - else if (headerLevel === 3) increment = 32; - else if (isSubtext) increment = 22; - - if (wordObjects.length === 0) { - currentY += baseLineHeight; - continue; - } + if (isQuote) lineIndent = 20; + + // 2. Inline Markdown to Pseudo-HTML conversion + const parsedLine = line + .replace(/\|\|([^|]+)\|\|/g, '$1') // Strip Spoilers + .replace(/<@!?\d+>/g, '@User') // Format Mentions + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') // Bold Italic + .replace(/\*\*(.+?)\*\*/g, '$1') // Bold + .replace(/\*(.+?)\*/g, '$1') // Italic + .replace(/__(.+?)__/g, '$1') // Underline + .replace(/~~(.+?)~~/g, '$1') // Strikethrough + .replace(/`([^`]+)`/g, '$1') // Inline Code + .replace( + /\[([^\]]+)\]\(color:(#[0-9a-fA-F]{3,6})\)/g, + '$1' + ); // Hex Colors + + // 3. Tokenize by our custom tags + const tokens = parsedLine + .split( + /(|<\/bi>||<\/b>||<\/i>||<\/u>||<\/s>||<\/c>||<\/col>)/g + ) + .filter(Boolean); + + const currentState = { + bold: headerLevel > 0, + italic: isQuote || isSubtext, + underline: false, + strike: false, + code: false, + color: isSubtext ? '#6b7280' : isQuote ? '#9ca3af' : defaultColor + }; - // Draws a full wrapped line to the canvas - const flushLine = () => { - if (lineWords.length === 0) return; - - if (draw) { - let drawX = startX + lineIndent; - for (const lw of lineWords) { - ctx.font = getFont(lw.state); - ctx.fillStyle = lw.state.color; - - const m = ctx.measureText(lw.word); - - if (lw.state.code) { - ctx.fillStyle = '#ffffff1a'; - ctx.fillRect(drawX, currentY - (increment * 0.7), m.width, increment); - ctx.fillStyle = lw.state.color; - } - - ctx.fillText(lw.word, drawX, currentY); - - // Underlines and Strikethroughs - if (lw.state.underline) { - ctx.fillRect(drawX, currentY + 4, m.width - (lw.word.endsWith(' ') ? 8 : 0), 2); - } - if (lw.state.strike) { - ctx.fillRect(drawX, currentY - (increment * 0.3), m.width - (lw.word.endsWith(' ') ? 8 : 0), 2); - } - - drawX += m.width; - } - } - currentY += increment; - lineWords = []; - currentLineWidth = 0; - }; - - // Measure & Wrap loop - for (let w = 0; w < wordObjects.length; w++) { - const wObj = wordObjects[w]; - ctx.font = getFont(wObj.state); - let metrics = ctx.measureText(wObj.word); - - if (currentLineWidth + metrics.width > maxWidth - lineIndent && lineWords.length > 0) { - flushLine(); - // Strip leading space on wrap - if (wObj.word.startsWith(' ')) { - wObj.word = wObj.word.substring(1); - metrics = ctx.measureText(wObj.word); - } - } - - lineWords.push(wObj); - currentLineWidth += metrics.width; - } + const wordObjects: { word: string; state: any }[] = []; - flushLine(); + // Inject the bullet point/number if it's a list + if (listPrefix) { + wordObjects.push({ + word: `${listPrefix} `, + state: { ...currentState, color: '#10b981', bold: true } + }); + } - // Draw Block Quote bar across the entire paragraph block - if (isQuote && draw) { - ctx.fillStyle = '#10b98180'; - ctx.fillRect(startX, startYOfParagraph - (increment * 0.7), 4, currentY - startYOfParagraph); + // Apply state toggles and chunk text into words + for (const token of tokens) { + if (token === '') { + currentState.bold = true; + currentState.italic = true; + } else if (token === '') { + currentState.bold = false; + currentState.italic = false; + } else if (token === '') currentState.bold = true; + else if (token === '') currentState.bold = false; + else if (token === '') currentState.italic = true; + else if (token === '') currentState.italic = false; + else if (token === '') currentState.underline = true; + else if (token === '') currentState.underline = false; + else if (token === '') currentState.strike = true; + else if (token === '') currentState.strike = false; + else if (token === '') { + currentState.code = true; + currentState.color = '#6ee7b7'; + } else if (token === '') { + currentState.code = false; + currentState.color = isSubtext + ? '#6b7280' + : isQuote + ? '#9ca3af' + : defaultColor; + } else if (token.startsWith('') { + currentState.color = isSubtext + ? '#6b7280' + : isQuote + ? '#9ca3af' + : defaultColor; + } else { + const textWords = token.split(' '); + for (let w = 0; w < textWords.length; w++) { + const wordStr = textWords[w] + (w < textWords.length - 1 ? ' ' : ''); + if (wordStr.length > 0) { + wordObjects.push({ word: wordStr, state: { ...currentState } }); + } } - - if (headerLevel > 0) currentY += 10; - else currentY += baseLineHeight * 0.3; // Paragraph margin + } } - return currentY - startY; - } + const getFont = (state: any): string => { + const weight = state.bold ? 'bold ' : ''; + const style = state.italic ? 'italic ' : ''; + let size = 22; // Base size - public static async build(data: IStepJSON | ICombatJSON): Promise { - - // --- NORMALIZE DATA PAYLOADS --- - const flavorText = data.flavorText || 'Waiting for input...'; - const enemyStats = data.enemy; - - const scenarioMeta = { - id: (data as IStepJSON).scenarioId || '0', - author: (data as IStepJSON).scenarioAuthor || 'SYSTEM' - }; + if (headerLevel === 1) size = 32; + else if (headerLevel === 2) size = 28; + else if (headerLevel === 3) size = 24; + else if (isSubtext) size = 16; + else if (state.code) size = 20; + + let family = state.code ? 'monospace' : 'sans-serif'; + if (!state.code && !headerLevel && !isSubtext) family = 'monospace'; - const pStats = data.playerStats || {}; - const level = pStats.level ?? 1; - const mappedStats = { - hp: Math.floor(pStats.stats?.hp ?? pStats.hp ?? 0), - maxHp: pStats.maxHp ?? 100, - level: level, - exp: Math.floor(pStats.experience ?? pStats.exp ?? 0), - gold: pStats.coins ?? pStats.gold ?? 0, - expRequired: pStats.expRequired ?? Math.floor(50 * Math.pow(Math.max(1, level), 1.3)), - activeBonuses: pStats.activeBonuses || {} + return `${style}${weight}${size}px ${family}`; }; - const inCombat = !!enemyStats; - const isDead = mappedStats.hp <= 0; - - const b = mappedStats.activeBonuses; - const hasBonuses = b && (b.critChance > 5 || b.lifeSteal > 0 || b.dodge > 0 || b.thorns > 0); - - // --- PRE-CALCULATE TEXT HEIGHT --- - const dummyCanvas = createCanvas(800, 10); - const dummyCtx = dummyCanvas.getContext('2d'); - const termW = 720; - - // Measure without drawing - const requiredTextHeight = this.processText(dummyCtx, flavorText, 0, 0, termW - 60, 32, '#ffffff', false); - - let extraHeight = 0; - const baseTextSpace = 160; - if (requiredTextHeight > baseTextSpace) { - extraHeight = requiredTextHeight - baseTextSpace + 20; // Stretch canvas + let lineWords: any[] = []; + let currentLineWidth = 0; + const startYOfParagraph = currentY; + + let increment = baseLineHeight; + if (headerLevel === 1) increment = 44; + else if (headerLevel === 2) increment = 38; + else if (headerLevel === 3) increment = 32; + else if (isSubtext) increment = 22; + + if (wordObjects.length === 0) { + currentY += baseLineHeight; + continue; } - // --- DYNAMIC CANVAS SIZING --- - let canvasHeight = 560 + extraHeight; - if (inCombat) canvasHeight += 75; - if (hasBonuses) canvasHeight += 40; + // Draws a full wrapped line to the canvas + const flushLine = (): void => { + if (lineWords.length === 0) return; + + if (draw) { + let drawX = startX + lineIndent; + for (const lw of lineWords) { + ctx.font = getFont(lw.state); + ctx.fillStyle = lw.state.color; + + const m = ctx.measureText(lw.word); + + if (lw.state.code) { + ctx.fillStyle = '#ffffff1a'; + ctx.fillRect(drawX, currentY - increment * 0.7, m.width, increment); + ctx.fillStyle = lw.state.color; + } + + ctx.fillText(lw.word, drawX, currentY); + + // Underlines and Strikethroughs + if (lw.state.underline) { + ctx.fillRect( + drawX, + currentY + 4, + m.width - (lw.word.endsWith(' ') ? 8 : 0), + 2 + ); + } + if (lw.state.strike) { + ctx.fillRect( + drawX, + currentY - increment * 0.3, + m.width - (lw.word.endsWith(' ') ? 8 : 0), + 2 + ); + } + + drawX += m.width; + } + } + currentY += increment; + lineWords = []; + currentLineWidth = 0; + }; - const canvas = createCanvas(800, canvasHeight); - const ctx = canvas.getContext('2d'); + // Measure & Wrap loop + for (const wObj of wordObjects) { + ctx.font = getFont(wObj.state); + let metrics = ctx.measureText(wObj.word); - const themeColor = inCombat || isDead ? '#ef4444' : '#10b981'; - const themeColorDim = inCombat || isDead ? '#ef444433' : '#10b98133'; + if ( + currentLineWidth + metrics.width > maxWidth - lineIndent && + lineWords.length > 0 + ) { + flushLine(); + // Strip leading space on wrap + if (wObj.word.startsWith(' ')) { + wObj.word = wObj.word.substring(1); + metrics = ctx.measureText(wObj.word); + } + } - // 1. Background - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + lineWords.push(wObj); + currentLineWidth += metrics.width; + } - ctx.strokeStyle = '#ffffff05'; - ctx.lineWidth = 1; - for (let i = 0; i < canvas.height; i += 20) { - ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(canvas.width, i); ctx.stroke(); + flushLine(); + + // Draw Block Quote bar across the entire paragraph block + if (isQuote && draw) { + ctx.fillStyle = '#10b98180'; + ctx.fillRect( + startX, + startYOfParagraph - increment * 0.7, + 4, + currentY - startYOfParagraph + ); } - // 2. Header - ctx.fillStyle = themeColor; - ctx.font = 'bold 36px sans-serif'; - ctx.textAlign = 'center'; - const headerText = isDead ? 'SYSTEM FAILURE' : (inCombat ? 'COMBAT ENGAGED' : 'ADVENTURE'); - ctx.fillText(headerText, canvas.width / 2, 60); + if (headerLevel > 0) currentY += 10; + else currentY += baseLineHeight * 0.3; // Paragraph margin + } + + return currentY - startY; +} + +export async function build(data: IStepJSON | ICombatJSON): Promise { + // --- NORMALIZE DATA PAYLOADS --- + const flavorText = data.flavorText || 'Waiting for input...'; + const enemyStats = data.enemy; + + const scenarioMeta = { + id: (data as IStepJSON).scenarioId || '0', + author: (data as IStepJSON).scenarioAuthor || 'SYSTEM' + }; + + const pStats = data.playerStats || {}; + const level = pStats.level ?? 1; + const mappedStats = { + hp: Math.floor(pStats.stats?.hp ?? pStats.hp ?? 0), + maxHp: pStats.maxHp ?? 100, + level, + exp: Math.floor(pStats.experience ?? pStats.exp ?? 0), + gold: pStats.coins ?? pStats.gold ?? 0, + expRequired: + pStats.expRequired ?? Math.floor(50 * Math.max(1, level) ** 1.3), + activeBonuses: pStats.activeBonuses || {} + }; + + const inCombat = !!enemyStats; + const isDead = mappedStats.hp <= 0; + + const b = mappedStats.activeBonuses; + const hasBonuses = + b && (b.critChance > 5 || b.lifeSteal > 0 || b.dodge > 0 || b.thorns > 0); + + // --- PRE-CALCULATE TEXT HEIGHT --- + const dummyCanvas = createCanvas(800, 10); + const dummyCtx = dummyCanvas.getContext('2d'); + const termW = 720; + + // Measure without drawing + const requiredTextHeight = processText( + dummyCtx, + flavorText, + 0, + 0, + termW - 60, + 32, + '#ffffff', + false + ); + + let extraHeight = 0; + const baseTextSpace = 160; + if (requiredTextHeight > baseTextSpace) { + extraHeight = requiredTextHeight - baseTextSpace + 20; // Stretch canvas + } + + // --- DYNAMIC CANVAS SIZING --- + let canvasHeight = 560 + extraHeight; + if (inCombat) canvasHeight += 75; + if (hasBonuses) canvasHeight += 40; + + const canvas = createCanvas(800, canvasHeight); + const ctx = canvas.getContext('2d'); + + const themeColor = inCombat || isDead ? '#ef4444' : '#10b981'; + const themeColorDim = inCombat || isDead ? '#ef444433' : '#10b98133'; - // 3. Terminal Window - const termX = 40; - const termY = 90; - const termH = 280 + extraHeight; // Stretched dynamically + // 1. Background + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#000000cc'; + ctx.strokeStyle = '#ffffff05'; + ctx.lineWidth = 1; + for (let i = 0; i < canvas.height; i += 20) { ctx.beginPath(); - ctx.roundRect(termX, termY, termW, termH, 12); - ctx.fill(); - ctx.lineWidth = 2; - ctx.strokeStyle = themeColorDim; + ctx.moveTo(0, i); + ctx.lineTo(canvas.width, i); ctx.stroke(); + } - ctx.fillStyle = '#ffffff0a'; + // 2. Header + ctx.fillStyle = themeColor; + ctx.font = 'bold 36px sans-serif'; + ctx.textAlign = 'center'; + const headerText = isDead + ? 'SYSTEM FAILURE' + : inCombat + ? 'COMBAT ENGAGED' + : 'ADVENTURE'; + ctx.fillText(headerText, canvas.width / 2, 60); + + // 3. Terminal Window + const termX = 40; + const termY = 90; + const termH = 280 + extraHeight; // Stretched dynamically + + ctx.fillStyle = '#000000cc'; + ctx.beginPath(); + ctx.roundRect(termX, termY, termW, termH, 12); + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = themeColorDim; + ctx.stroke(); + + ctx.fillStyle = '#ffffff0a'; + ctx.beginPath(); + ctx.roundRect(termX, termY, termW, 30, [12, 12, 0, 0]); + ctx.fill(); + + const dotY = termY + 15; + ctx.fillStyle = isDead ? '#dc2626' : '#ef4444'; + ctx.beginPath(); + ctx.arc(termX + 20, dotY, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#f59e0b'; + ctx.beginPath(); + ctx.arc(termX + 40, dotY, 6, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#10b981'; + ctx.beginPath(); + ctx.arc(termX + 60, dotY, 6, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#6b7280'; + ctx.font = '12px monospace'; + ctx.textAlign = 'left'; + ctx.fillText( + inCombat + ? 'combat_protocol.exe' + : isDead + ? 'system_dump.log' + : 'adventure_logs.sh', + termX + 80, + termY + 20 + ); + + ctx.fillStyle = '#ffffff0a'; + ctx.fillRect(termX, termY + termH - 25, termW, 25); + ctx.fillStyle = '#4b5563'; + ctx.font = '10px monospace'; + ctx.fillText( + `ID: ${scenarioMeta.id.toString().padStart(6, '0')}`, + termX + 15, + termY + termH - 8 + ); + ctx.textAlign = 'right'; + ctx.fillText( + `Author: ${scenarioMeta.author}`, + termX + termW - 15, + termY + termH - 8 + ); + + const textColor = isDead ? '#fca5a5' : inCombat ? '#fca5a5' : themeColor; + ctx.fillStyle = textColor; + ctx.font = '22px monospace'; + ctx.textAlign = 'left'; + ctx.fillText('>', termX + 20, termY + 60); + + // Execute the final drawing with markdown support! + processText( + ctx, + flavorText, + termX + 40, + termY + 60, + termW - 60, + 32, + textColor, + true + ); + + let yOffset = termY + termH + 25; + + // 4. Enemy Stats + if (inCombat && enemyStats && !isDead) { + ctx.fillStyle = '#450a0a'; + ctx.strokeStyle = '#ef44444d'; ctx.beginPath(); - ctx.roundRect(termX, termY, termW, 30, [12, 12, 0, 0]); + ctx.roundRect(termX, yOffset, 60, 45, 6); ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#ef4444b3'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('ATK', termX + 30, yOffset + 18); + ctx.fillStyle = '#f87171'; + ctx.font = 'bold 16px monospace'; + ctx.fillText(enemyStats.atk.toString(), termX + 30, yOffset + 38); - const dotY = termY + 15; - ctx.fillStyle = isDead ? '#dc2626' : '#ef4444'; - ctx.beginPath(); ctx.arc(termX + 20, dotY, 6, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = '#f59e0b'; - ctx.beginPath(); ctx.arc(termX + 40, dotY, 6, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = '#10b981'; - ctx.beginPath(); ctx.arc(termX + 60, dotY, 6, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#172554'; + ctx.strokeStyle = '#3b82f64d'; + ctx.beginPath(); + ctx.roundRect(termX + termW - 60, yOffset, 60, 45, 6); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#3b82f6b3'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('DEF', termX + termW - 30, yOffset + 18); + ctx.fillStyle = '#60a5fa'; + ctx.font = 'bold 16px monospace'; + ctx.fillText(enemyStats.def.toString(), termX + termW - 30, yOffset + 38); - ctx.fillStyle = '#6b7280'; - ctx.font = '12px monospace'; + const eBarX = termX + 75; + const eBarW = termW - 150; + ctx.fillStyle = '#f87171'; + ctx.font = 'bold 14px sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(inCombat ? 'combat_protocol.exe' : (isDead ? 'system_dump.log' : 'adventure_logs.sh'), termX + 80, termY + 20); - - ctx.fillStyle = '#ffffff0a'; - ctx.fillRect(termX, termY + termH - 25, termW, 25); - ctx.fillStyle = '#4b5563'; - ctx.font = '10px monospace'; - ctx.fillText(`ID: ${scenarioMeta.id.toString().padStart(6, '0')}`, termX + 15, termY + termH - 8); + ctx.fillText(enemyStats.name, eBarX, yOffset + 15); ctx.textAlign = 'right'; - ctx.fillText(`Author: ${scenarioMeta.author}`, termX + termW - 15, termY + termH - 8); + ctx.fillText( + `${Math.max(0, enemyStats.currentHp)} / ${enemyStats.maxHp} HP`, + eBarX + eBarW, + yOffset + 15 + ); - const textColor = isDead ? '#fca5a5' : (inCombat ? '#fca5a5' : themeColor); - ctx.fillStyle = textColor; - ctx.font = '22px monospace'; - ctx.textAlign = 'left'; - ctx.fillText('>', termX + 20, termY + 60); - - // Execute the final drawing with markdown support! - this.processText(ctx, flavorText, termX + 40, termY + 60, termW - 60, 32, textColor, true); - - let yOffset = termY + termH + 25; - - // 4. Enemy Stats - if (inCombat && enemyStats && !isDead) { - ctx.fillStyle = '#450a0a'; - ctx.strokeStyle = '#ef44444d'; - ctx.beginPath(); ctx.roundRect(termX, yOffset, 60, 45, 6); ctx.fill(); ctx.stroke(); - ctx.fillStyle = '#ef4444b3'; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('ATK', termX + 30, yOffset + 18); - ctx.fillStyle = '#f87171'; ctx.font = 'bold 16px monospace'; ctx.fillText(enemyStats.atk.toString(), termX + 30, yOffset + 38); - - ctx.fillStyle = '#172554'; - ctx.strokeStyle = '#3b82f64d'; - ctx.beginPath(); ctx.roundRect(termX + termW - 60, yOffset, 60, 45, 6); ctx.fill(); ctx.stroke(); - ctx.fillStyle = '#3b82f6b3'; ctx.font = 'bold 10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('DEF', termX + termW - 30, yOffset + 18); - ctx.fillStyle = '#60a5fa'; ctx.font = 'bold 16px monospace'; ctx.fillText(enemyStats.def.toString(), termX + termW - 30, yOffset + 38); - - const eBarX = termX + 75; - const eBarW = termW - 150; - ctx.fillStyle = '#f87171'; - ctx.font = 'bold 14px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(enemyStats.name, eBarX, yOffset + 15); - ctx.textAlign = 'right'; - ctx.fillText(`${Math.max(0, enemyStats.currentHp)} / ${enemyStats.maxHp} HP`, eBarX + eBarW, yOffset + 15); - - ctx.fillStyle = '#ffffff1a'; - ctx.beginPath(); ctx.roundRect(eBarX, yOffset + 25, eBarW, 12, 6); ctx.fill(); - const eHpPercent = Math.max(0, Math.min(enemyStats.currentHp / enemyStats.maxHp, 1)); - ctx.fillStyle = '#dc2626'; - ctx.beginPath(); ctx.roundRect(eBarX, yOffset + 25, eBarW * eHpPercent, 12, 6); ctx.fill(); - - yOffset += 75; - } + ctx.fillStyle = '#ffffff1a'; + ctx.beginPath(); + ctx.roundRect(eBarX, yOffset + 25, eBarW, 12, 6); + ctx.fill(); + const eHpPercent = Math.max( + 0, + Math.min(enemyStats.currentHp / enemyStats.maxHp, 1) + ); + ctx.fillStyle = '#dc2626'; + ctx.beginPath(); + ctx.roundRect(eBarX, yOffset + 25, eBarW * eHpPercent, 12, 6); + ctx.fill(); - // 5. Player Stats - const hpPercent = Math.max(0, Math.min(mappedStats.hp / mappedStats.maxHp, 1)); - const expPercent = Math.max(0, Math.min(mappedStats.exp / mappedStats.expRequired, 1)); + yOffset += 75; + } - ctx.fillStyle = isDead ? '#ef4444' : '#34d399'; - ctx.font = 'bold 14px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText('Player HP', termX, yOffset); - ctx.textAlign = 'right'; - ctx.fillText(`${mappedStats.hp} / ${mappedStats.maxHp}`, termX + termW, yOffset); + // 5. Player Stats + const hpPercent = Math.max( + 0, + Math.min(mappedStats.hp / mappedStats.maxHp, 1) + ); + const expPercent = Math.max( + 0, + Math.min(mappedStats.exp / mappedStats.expRequired, 1) + ); + + ctx.fillStyle = isDead ? '#ef4444' : '#34d399'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('Player HP', termX, yOffset); + ctx.textAlign = 'right'; + ctx.fillText( + `${mappedStats.hp} / ${mappedStats.maxHp}`, + termX + termW, + yOffset + ); + + ctx.fillStyle = '#ffffff1a'; + ctx.beginPath(); + ctx.roundRect(termX, yOffset + 12, termW, 12, 6); + ctx.fill(); + ctx.fillStyle = isDead ? '#dc2626' : '#10b981'; + ctx.beginPath(); + ctx.roundRect(termX, yOffset + 12, termW * hpPercent, 12, 6); + ctx.fill(); + + yOffset += 45; + + ctx.fillStyle = '#60a5fa'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`Level ${mappedStats.level}`, termX, yOffset); + ctx.fillStyle = '#6b7280'; + ctx.textAlign = 'right'; + ctx.fillText( + `${mappedStats.exp} / ${mappedStats.expRequired} XP`, + termX + termW, + yOffset + ); + + ctx.fillStyle = '#ffffff1a'; + ctx.beginPath(); + ctx.roundRect(termX, yOffset + 12, termW, 12, 6); + ctx.fill(); + ctx.fillStyle = '#3b82f6'; + ctx.beginPath(); + ctx.roundRect(termX, yOffset + 12, termW * expPercent, 12, 6); + ctx.fill(); + + yOffset += 40; + + // 6. Active Bonuses + if (hasBonuses) { + let pillX = termX; + + const drawBonusPill = ( + label: string, + value: string, + bgColor: string, + borderColor: string, + textColor: string + ): void => { + ctx.font = 'bold 10px sans-serif'; + const text = `${label}: ${value}`; + const textWidth = ctx.measureText(text).width; + + ctx.fillStyle = bgColor; + ctx.strokeStyle = borderColor; + ctx.beginPath(); + ctx.roundRect(pillX, yOffset, textWidth + 16, 20, 4); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = textColor; + ctx.textAlign = 'center'; + ctx.fillText(text, pillX + (textWidth + 16) / 2, yOffset + 14); + + pillX += textWidth + 24; + }; - ctx.fillStyle = '#ffffff1a'; - ctx.beginPath(); ctx.roundRect(termX, yOffset + 12, termW, 12, 6); ctx.fill(); - ctx.fillStyle = isDead ? '#dc2626' : '#10b981'; - ctx.beginPath(); ctx.roundRect(termX, yOffset + 12, termW * hpPercent, 12, 6); ctx.fill(); + if (b.critChance > 5) + drawBonusPill( + 'Crit', + `${b.critChance}%`, + '#713f1233', + '#eab30833', + '#facc15' + ); + if (b.lifeSteal > 0) + drawBonusPill( + 'Vamp', + `${b.lifeSteal}%`, + '#450a0a33', + '#ef444433', + '#f87171' + ); + if (b.dodge > 0) + drawBonusPill( + 'Dodge', + `${b.dodge}%`, + '#17255433', + '#3b82f633', + '#93c5fd' + ); + if (b.thorns > 0) + drawBonusPill( + 'Thorns', + `${b.thorns}`, + '#7c2d1233', + '#f9731633', + '#fb923c' + ); + + yOffset += 35; + } - yOffset += 45; + // 7. Footer (Gold) + ctx.fillStyle = '#fbbf24'; + ctx.textAlign = 'left'; + ctx.font = '16px "NotoEmoji", sans-serif'; + ctx.fillText('๐Ÿช™', termX, yOffset + 15); + + ctx.font = 'bold 16px sans-serif'; + ctx.fillText( + ` GOLD ${mappedStats.gold.toLocaleString()}`, + termX + 22, + yOffset + 15 + ); + + // 8. TOAST NOTIFICATIONS (Rewards) + const rewards = (data as any).rewards; + if (rewards) { + const toasts: { msg: string; color: string; icon: string }[] = []; + + if (rewards.xp) + toasts.push({ msg: `+${rewards.xp} XP`, color: '#3b82f6', icon: 'โœจ' }); + if (rewards.gold) + toasts.push({ + msg: `+${rewards.gold} Gold`, + color: '#eab308', + icon: '๐Ÿช™' + }); + if (rewards.levelsGained > 0) + toasts.push({ msg: 'LEVEL UP!', color: '#10b981', icon: '๐Ÿ†™' }); + if (rewards.item) { + const RARITY_COLORS: Record = { + Common: '#b0b0b0', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff', + Exotic: '#ff00cc' + }; + const itemColor = RARITY_COLORS[rewards.item.rarity] || '#ffffff'; + toasts.push({ msg: rewards.item.name, color: itemColor, icon: '๐ŸŽ’' }); + } - ctx.fillStyle = '#60a5fa'; - ctx.font = 'bold 14px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(`Level ${mappedStats.level}`, termX, yOffset); - ctx.fillStyle = '#6b7280'; - ctx.textAlign = 'right'; - ctx.fillText(`${mappedStats.exp} / ${mappedStats.expRequired} XP`, termX + termW, yOffset); + let toastY = 30; + for (const toast of toasts) { + ctx.font = 'bold 14px sans-serif'; + const msgWidth = ctx.measureText(toast.msg).width; + const toastW = msgWidth + 60; + const toastH = 40; - ctx.fillStyle = '#ffffff1a'; - ctx.beginPath(); ctx.roundRect(termX, yOffset + 12, termW, 12, 6); ctx.fill(); - ctx.fillStyle = '#3b82f6'; - ctx.beginPath(); ctx.roundRect(termX, yOffset + 12, termW * expPercent, 12, 6); ctx.fill(); - - yOffset += 40; - - // 6. Active Bonuses - if (hasBonuses) { - let pillX = termX; - - const drawBonusPill = (label: string, value: string, bgColor: string, borderColor: string, textColor: string) => { - ctx.font = 'bold 10px sans-serif'; - const text = `${label}: ${value}`; - const textWidth = ctx.measureText(text).width; - - ctx.fillStyle = bgColor; - ctx.strokeStyle = borderColor; - ctx.beginPath(); ctx.roundRect(pillX, yOffset, textWidth + 16, 20, 4); ctx.fill(); ctx.stroke(); - - ctx.fillStyle = textColor; - ctx.textAlign = 'center'; - ctx.fillText(text, pillX + (textWidth + 16) / 2, yOffset + 14); - - pillX += textWidth + 24; - }; - - if (b.critChance > 5) drawBonusPill('Crit', `${b.critChance}%`, '#713f1233', '#eab30833', '#facc15'); - if (b.lifeSteal > 0) drawBonusPill('Vamp', `${b.lifeSteal}%`, '#450a0a33', '#ef444433', '#f87171'); - if (b.dodge > 0) drawBonusPill('Dodge', `${b.dodge}%`, '#17255433', '#3b82f633', '#93c5fd'); - if (b.thorns > 0) drawBonusPill('Thorns', `${b.thorns}`, '#7c2d1233', '#f9731633', '#fb923c'); - - yOffset += 35; - } + ctx.fillStyle = '#0a0a0ae6'; + ctx.beginPath(); + ctx.roundRect(0, toastY, toastW, toastH, [0, 8, 8, 0]); + ctx.fill(); - // 7. Footer (Gold) - ctx.fillStyle = '#fbbf24'; - ctx.textAlign = 'left'; - ctx.font = '16px "NotoEmoji", sans-serif'; - ctx.fillText('๐Ÿช™', termX, yOffset + 15); - - ctx.font = 'bold 16px sans-serif'; - ctx.fillText(` GOLD ${mappedStats.gold.toLocaleString()}`, termX + 22, yOffset + 15); - - // 8. TOAST NOTIFICATIONS (Rewards) - const rewards = (data as any).rewards; - if (rewards) { - const toasts: { msg: string, color: string, icon: string }[] = []; - - if (rewards.xp) toasts.push({ msg: `+${rewards.xp} XP`, color: '#3b82f6', icon: 'โœจ' }); - if (rewards.gold) toasts.push({ msg: `+${rewards.gold} Gold`, color: '#eab308', icon: '๐Ÿช™' }); - if (rewards.levelsGained > 0) toasts.push({ msg: 'LEVEL UP!', color: '#10b981', icon: '๐Ÿ†™' }); - if (rewards.item) { - const RARITY_COLORS: Record = { Common: '#b0b0b0', Uncommon: '#2ecc71', Rare: '#3498db', Elite: '#e67e22', Epic: '#9b59b6', Legendary: '#f1c40f', Divine: '#00e5ff', Exotic: '#ff00cc' }; - const itemColor = RARITY_COLORS[rewards.item.rarity] || '#ffffff'; - toasts.push({ msg: rewards.item.name, color: itemColor, icon: '๐ŸŽ’' }); - } + ctx.lineWidth = 1; + ctx.strokeStyle = `${toast.color}40`; + ctx.stroke(); - let toastY = 30; - for (const toast of toasts) { - ctx.font = 'bold 14px sans-serif'; - const msgWidth = ctx.measureText(toast.msg).width; - const toastW = msgWidth + 60; - const toastH = 40; - - ctx.fillStyle = '#0a0a0ae6'; - ctx.beginPath(); ctx.roundRect(0, toastY, toastW, toastH, [0, 8, 8, 0]); ctx.fill(); - - ctx.lineWidth = 1; - ctx.strokeStyle = `${toast.color}40`; - ctx.stroke(); - - ctx.fillStyle = toast.color; - ctx.fillRect(0, toastY, 4, toastH); - - ctx.font = '16px "NotoEmoji", sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(toast.icon, 24, toastY + 26); - - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 14px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(toast.msg, 44, toastY + 25); - - toastY += toastH + 10; - } - } + ctx.fillStyle = toast.color; + ctx.fillRect(0, toastY, 4, toastH); - return canvas.toBuffer('image/png'); + ctx.font = '16px "NotoEmoji", sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(toast.icon, 24, toastY + 26); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(toast.msg, 44, toastY + 25); + + toastY += toastH + 10; + } } -} \ No newline at end of file + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/ApiClient.ts b/src/utilities/ApiClient.ts index d6393b1..91ef1c4 100644 --- a/src/utilities/ApiClient.ts +++ b/src/utilities/ApiClient.ts @@ -1,4 +1,4 @@ -import Routes from './Routes'; +import * as Routes from './Routes'; import logger from './Logger'; const DEFAULT_TIMEOUT = 10_000; // 10 seconds @@ -27,7 +27,9 @@ class CircuitBreaker { this.failures++; if (this.failures >= CIRCUIT_BREAKER_THRESHOLD) { this.openUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN; - logger.warn(`[ApiClient] Circuit breaker OPEN โ€” API unreachable after ${this.failures} failures. Retrying in ${CIRCUIT_BREAKER_COOLDOWN / 1000}s`); + logger.warn( + `[ApiClient] Circuit breaker OPEN โ€” API unreachable after ${this.failures} failures. Retrying in ${CIRCUIT_BREAKER_COOLDOWN / 1000}s` + ); } } } @@ -38,9 +40,16 @@ const breaker = new CircuitBreaker(); * Centralized API fetch wrapper. * Provides: default headers, request timeout, circuit breaker, structured error context. */ -export async function apiFetch(url: string, options?: RequestInit): Promise { +export async function apiFetch( + url: string, + options?: RequestInit +): Promise { if (breaker.isOpen()) { - throw new ApiError('API_UNAVAILABLE', 'The game server is temporarily unreachable. Please try again in a moment.', 503); + throw new ApiError( + 'API_UNAVAILABLE', + 'The game server is temporarily unreachable. Please try again in a moment.', + 503 + ); } try { @@ -48,12 +57,17 @@ export async function apiFetch(url: string, options?: RequestInit): Promise= 500) { @@ -65,10 +79,18 @@ export async function apiFetch(url: string, options?: RequestInit): Promise = { - Common: '#b0b0b0', Uncommon: '#2ecc71', Rare: '#3498db', - Elite: '#e67e22', Epic: '#9b59b6', Legendary: '#f1c40f', - Divine: '#00e5ff', + Common: '#b0b0b0', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff' }; const TIER_EMOJIS: Record = { - Common: '๐Ÿ“ฆ', Uncommon: '๐ŸŸข', Rare: '๐Ÿ”ต', Elite: '๐ŸŸ ', - Epic: '๐ŸŸฃ', Legendary: 'โญ', Divine: '๐Ÿ’Ž', + Common: '๐Ÿ“ฆ', + Uncommon: '๐ŸŸข', + Rare: '๐Ÿ”ต', + Elite: '๐ŸŸ ', + Epic: '๐ŸŸฃ', + Legendary: 'โญ', + Divine: '๐Ÿ’Ž' }; export interface ChestsPageConfig { @@ -22,175 +36,185 @@ export interface ChestsPageConfig { totalOpened: number; } -export default class ChestsImageBuilder { - public static async build(chests: IChestSlot[], config: ChestsPageConfig): Promise { - const slotW = 160; - const slotH = 200; - const cols = 4; - const rows = Math.ceil(config.maxSlots / cols); - const padding = 30; - const gap = 15; - const headerH = 100; - const canvasW = padding * 2 + cols * slotW + (cols - 1) * gap; - const canvasH = headerH + rows * (slotH + gap) + 60; - - const canvas = createCanvas(canvasW, canvasH); - const ctx = canvas.getContext('2d'); - - // Background - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Header gradient - const grad = ctx.createLinearGradient(0, 0, 0, 100); - grad.addColorStop(0, '#1a1a1a'); - grad.addColorStop(1, '#0a0a0a'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, canvas.width, 100); - - // Title - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 28px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText('๐ŸŽ', padding, 45); - ctx.fillText('CHEST VAULT', padding + 42, 45); - - // Stats line - ctx.fillStyle = '#6b7280'; - ctx.font = '11px sans-serif'; - ctx.fillText(`${chests.length} / ${config.maxSlots} slots โ€ข ${config.totalOpened} opened`, padding, 70); - - // Pity progress - ctx.textAlign = 'right'; +export async function build( + chests: IChestSlot[], + config: ChestsPageConfig +): Promise { + const slotW = 160; + const slotH = 200; + const cols = 4; + const rows = Math.ceil(config.maxSlots / cols); + const padding = 30; + const gap = 15; + const headerH = 100; + const canvasW = padding * 2 + cols * slotW + (cols - 1) * gap; + const canvasH = headerH + rows * (slotH + gap) + 60; + + const canvas = createCanvas(canvasW, canvasH); + const ctx = canvas.getContext('2d'); + + // Background + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Header gradient + const grad = ctx.createLinearGradient(0, 0, 0, 100); + grad.addColorStop(0, '#1a1a1a'); + grad.addColorStop(1, '#0a0a0a'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, canvas.width, 100); + + // Title + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 28px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('๐ŸŽ', padding, 45); + ctx.fillText('CHEST VAULT', padding + 42, 45); + + // Stats line + ctx.fillStyle = '#6b7280'; + ctx.font = '11px sans-serif'; + ctx.fillText( + `${chests.length} / ${config.maxSlots} slots โ€ข ${config.totalOpened} opened`, + padding, + 70 + ); + + // Pity progress + ctx.textAlign = 'right'; + ctx.fillStyle = '#00e5ff'; + ctx.font = 'bold 11px sans-serif'; + ctx.fillText( + `Divine Pity: ${config.divinePity}/${config.pityThreshold}`, + canvas.width - padding, + 40 + ); + + // Pity bar + const pityBarX = canvas.width - padding - 200; + const pityBarY = 52; + const pityBarW = 200; + const pityPct = Math.min(1, config.divinePity / config.pityThreshold); + + ctx.fillStyle = '#ffffff10'; + ctx.beginPath(); + ctx.roundRect(pityBarX, pityBarY, pityBarW, 8, 4); + ctx.fill(); + + if (pityPct > 0) { ctx.fillStyle = '#00e5ff'; - ctx.font = 'bold 11px sans-serif'; - ctx.fillText(`Divine Pity: ${config.divinePity}/${config.pityThreshold}`, canvas.width - padding, 40); - - // Pity bar - const pityBarX = canvas.width - padding - 200; - const pityBarY = 52; - const pityBarW = 200; - const pityPct = Math.min(1, config.divinePity / config.pityThreshold); + ctx.beginPath(); + ctx.roundRect(pityBarX, pityBarY, pityBarW * pityPct, 8, 4); + ctx.fill(); + } - ctx.fillStyle = '#ffffff10'; + // Divider + ctx.beginPath(); + ctx.moveTo(padding, 85); + ctx.lineTo(canvas.width - padding, 85); + ctx.strokeStyle = '#ffffff1a'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Chest Slots + for (let i = 0; i < config.maxSlots; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const x = padding + col * (slotW + gap); + const y = headerH + row * (slotH + gap); + + const chest = chests[i]; + + // Slot background + ctx.fillStyle = chest ? '#ffffff08' : '#ffffff03'; ctx.beginPath(); - ctx.roundRect(pityBarX, pityBarY, pityBarW, 8, 4); + ctx.roundRect(x, y, slotW, slotH, 12); ctx.fill(); - if (pityPct > 0) { - ctx.fillStyle = '#00e5ff'; - ctx.beginPath(); - ctx.roundRect(pityBarX, pityBarY, pityBarW * pityPct, 8, 4); - ctx.fill(); + if (!chest) { + // Empty slot + ctx.strokeStyle = '#ffffff0a'; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = '#ffffff15'; + ctx.font = '40px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('+', x + slotW / 2, y + slotH / 2 + 12); + continue; } - // Divider - ctx.beginPath(); - ctx.moveTo(padding, 85); - ctx.lineTo(canvas.width - padding, 85); - ctx.strokeStyle = '#ffffff1a'; - ctx.lineWidth = 1; + const color = TIER_COLORS[chest.tier] || '#ffffff'; + const emoji = chest.emoji || TIER_EMOJIS[chest.tier] || '๐Ÿ“ฆ'; + + // Border glow based on status + if (chest.status === 'ready') { + ctx.strokeStyle = `${color}88`; + ctx.lineWidth = 2; + } else if (chest.status === 'unlocking') { + ctx.strokeStyle = `${color}44`; + ctx.lineWidth = 1; + } else { + ctx.strokeStyle = '#ffffff15'; + ctx.lineWidth = 1; + } ctx.stroke(); - // Chest Slots - for (let i = 0; i < config.maxSlots; i++) { - const col = i % cols; - const row = Math.floor(i / cols); - const x = padding + col * (slotW + gap); - const y = headerH + row * (slotH + gap); - - const chest = chests[i]; - - // Slot background - ctx.fillStyle = chest ? '#ffffff08' : '#ffffff03'; + // Chest emoji + ctx.font = '50px "NotoEmoji", sans-serif'; + ctx.textAlign = 'center'; + ctx.globalAlpha = chest.status === 'locked' ? 0.5 : 1; + ctx.fillText(emoji, x + slotW / 2, y + 75); + ctx.globalAlpha = 1; + + // Tier name + ctx.fillStyle = color; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText(chest.tier, x + slotW / 2, y + 110); + + // Status + ctx.font = 'bold 10px sans-serif'; + if (chest.status === 'ready') { + ctx.fillStyle = '#34d399'; + ctx.fillText('โœ“ READY TO OPEN', x + slotW / 2, y + 135); + } else if (chest.status === 'unlocking') { + const remainSec = Math.max(0, Math.floor(chest.remainingMs / 1000)); + const h = Math.floor(remainSec / 3600); + const m = Math.floor(remainSec % 3600 / 60); + const s = remainSec % 60; + const timeStr = + h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`; + + ctx.fillStyle = '#eab308'; + ctx.fillText(`โณ ${timeStr}`, x + slotW / 2, y + 135); + + // Unlock progress bar + const totalSec = chest.unlockSeconds; + const elapsed = totalSec - remainSec; + const pct = Math.min(1, elapsed / totalSec); + + ctx.fillStyle = '#ffffff10'; ctx.beginPath(); - ctx.roundRect(x, y, slotW, slotH, 12); + ctx.roundRect(x + 20, y + 148, slotW - 40, 6, 3); ctx.fill(); - if (!chest) { - // Empty slot - ctx.strokeStyle = '#ffffff0a'; - ctx.lineWidth = 1; - ctx.stroke(); - - ctx.fillStyle = '#ffffff15'; - ctx.font = '40px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('+', x + slotW / 2, y + slotH / 2 + 12); - continue; - } - - const color = TIER_COLORS[chest.tier] || '#ffffff'; - const emoji = chest.emoji || TIER_EMOJIS[chest.tier] || '๐Ÿ“ฆ'; - - // Border glow based on status - if (chest.status === 'ready') { - ctx.strokeStyle = `${color}88`; - ctx.lineWidth = 2; - } else if (chest.status === 'unlocking') { - ctx.strokeStyle = `${color}44`; - ctx.lineWidth = 1; - } else { - ctx.strokeStyle = '#ffffff15'; - ctx.lineWidth = 1; - } - ctx.stroke(); - - // Chest emoji - ctx.font = '50px "NotoEmoji", sans-serif'; - ctx.textAlign = 'center'; - ctx.globalAlpha = chest.status === 'locked' ? 0.5 : 1; - ctx.fillText(emoji, x + slotW / 2, y + 75); - ctx.globalAlpha = 1; - - // Tier name - ctx.fillStyle = color; - ctx.font = 'bold 14px sans-serif'; - ctx.fillText(chest.tier, x + slotW / 2, y + 110); - - // Status - ctx.font = 'bold 10px sans-serif'; - if (chest.status === 'ready') { - ctx.fillStyle = '#34d399'; - ctx.fillText('โœ“ READY TO OPEN', x + slotW / 2, y + 135); - } else if (chest.status === 'unlocking') { - const remainSec = Math.max(0, Math.floor(chest.remainingMs / 1000)); - const h = Math.floor(remainSec / 3600); - const m = Math.floor((remainSec % 3600) / 60); - const s = remainSec % 60; - const timeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`; - + if (pct > 0) { ctx.fillStyle = '#eab308'; - ctx.fillText(`โณ ${timeStr}`, x + slotW / 2, y + 135); - - // Unlock progress bar - const totalSec = chest.unlockSeconds; - const elapsed = totalSec - remainSec; - const pct = Math.min(1, elapsed / totalSec); - - ctx.fillStyle = '#ffffff10'; ctx.beginPath(); - ctx.roundRect(x + 20, y + 148, slotW - 40, 6, 3); + ctx.roundRect(x + 20, y + 148, (slotW - 40) * pct, 6, 3); ctx.fill(); - - if (pct > 0) { - ctx.fillStyle = '#eab308'; - ctx.beginPath(); - ctx.roundRect(x + 20, y + 148, (slotW - 40) * pct, 6, 3); - ctx.fill(); - } - } else { - ctx.fillStyle = '#6b7280'; - ctx.fillText('๐Ÿ”’ LOCKED', x + slotW / 2, y + 135); } - - // Slot number - ctx.fillStyle = '#4b5563'; - ctx.font = '9px monospace'; - ctx.textAlign = 'right'; - ctx.fillText(`#${i + 1}`, x + slotW - 10, y + slotH - 10); + } else { + ctx.fillStyle = '#6b7280'; + ctx.fillText('๐Ÿ”’ LOCKED', x + slotW / 2, y + 135); } - return canvas.toBuffer('image/png'); + // Slot number + ctx.fillStyle = '#4b5563'; + ctx.font = '9px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(`#${i + 1}`, x + slotW - 10, y + slotH - 10); } -} \ No newline at end of file + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/CombatResponseBuilder.ts b/src/utilities/CombatResponseBuilder.ts index 5b56eaa..90e3d53 100644 --- a/src/utilities/CombatResponseBuilder.ts +++ b/src/utilities/CombatResponseBuilder.ts @@ -1,7 +1,13 @@ -import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; -import { ICombatJSON } from '../interfaces/ICombatJSON'; -import { IStepJSON } from '../interfaces/IStepJSON'; -import ImageService from './ImageService'; +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder +} from 'discord.js'; +import { type ICombatJSON } from '../interfaces/ICombatJSON'; +import { type IStepJSON } from '../interfaces/IStepJSON'; +import * as ImageService from './ImageService'; export interface CombatResponse { embeds: EmbedBuilder[]; @@ -15,9 +21,13 @@ export interface CombatResponse { * * Single source of truth for the combat UI โ€” change it here, changes everywhere. */ -export async function buildCombatResponse(data: ICombatJSON | IStepJSON): Promise { +export async function buildCombatResponse( + data: ICombatJSON | IStepJSON +): Promise { const imageBuffer = await ImageService.adventure(data); - const attachment = new AttachmentBuilder(imageBuffer, { name: 'adventure.png' }); + const attachment = new AttachmentBuilder(imageBuffer, { + name: 'adventure.png' + }); const hasEnemy = !!(data as any).enemy; const embed = new EmbedBuilder() @@ -32,8 +42,7 @@ export async function buildCombatResponse(data: ICombatJSON | IStepJSON): Promis const playerStats = isStepData.playerStats; const showCombatButtons = - (isCombatData.combatEnded === false) || - (isStepData.combatTrigger === true); + isCombatData.combatEnded === false || isStepData.combatTrigger === true; if (showCombatButtons) { const row = new ActionRowBuilder().setComponents( @@ -44,7 +53,7 @@ export async function buildCombatResponse(data: ICombatJSON | IStepJSON): Promis new ButtonBuilder() .setCustomId('embedFlee') .setLabel('Flee') - .setStyle(ButtonStyle.Secondary), + .setStyle(ButtonStyle.Secondary) ); components.push(row); } else { @@ -65,7 +74,11 @@ export async function buildCombatResponse(data: ICombatJSON | IStepJSON): Promis } // Rest button if HP < max and not dead - if (playerStats && playerStats.hp > 0 && playerStats.hp < playerStats.maxHp) { + if ( + playerStats && + playerStats.hp > 0 && + playerStats.hp < playerStats.maxHp + ) { actionRow.addComponents( new ButtonBuilder() .setCustomId('rest') @@ -97,7 +110,9 @@ export async function buildCombatResponse(data: ICombatJSON | IStepJSON): Promis } if (rewards?.chestDrop) { - descParts.push(`๐Ÿ“ฆ Found a **${rewards.chestDrop} Chest** while exploring!`); + descParts.push( + `๐Ÿ“ฆ Found a **${rewards.chestDrop} Chest** while exploring!` + ); } if (descParts.length > 0) { @@ -105,4 +120,4 @@ export async function buildCombatResponse(data: ICombatJSON | IStepJSON): Promis } return { embeds: [embed], files: [attachment], components }; -} \ No newline at end of file +} diff --git a/src/utilities/ErrorMessages.ts b/src/utilities/ErrorMessages.ts index d13d7a8..03d1273 100644 --- a/src/utilities/ErrorMessages.ts +++ b/src/utilities/ErrorMessages.ts @@ -5,27 +5,46 @@ const ERROR_MAP: Record = { // API error codes (structured) - 'PLAYER_NOT_FOUND': '๐Ÿ“œ **Adventurer not found!** Begin your journey with .', - 'IN_COMBAT': 'โš”๏ธ **You\'re already in battle!** Use `/attack` to fight or `/flee` to escape.', - 'INCAPACITATED': '๐Ÿ’€ **You have fallen!** Your wounds are too severe. Rest and recover before venturing out again.', - 'NO_ACTIVE_COMBAT': '๐ŸŒฟ **No enemy in sight.** Use `/explore` to find your next encounter.', - 'API_UNAVAILABLE': '๐Ÿ”ง **The realm is under maintenance.** The game server is temporarily unreachable. Please try again in a moment.', - 'API_TIMEOUT': 'โณ **The winds of fate are slow today.** The game server took too long to respond. Please try again.', - 'API_NETWORK_ERROR': '๐ŸŒ **Lost connection to the realm.** Could not reach the game server. Please try again later.', + PLAYER_NOT_FOUND: + '๐Ÿ“œ **Adventurer not found!** Begin your journey with .', + IN_COMBAT: + "โš”๏ธ **You're already in battle!** Use `/attack` to fight or `/flee` to escape.", + INCAPACITATED: + '๐Ÿ’€ **You have fallen!** Your wounds are too severe. Rest and recover before venturing out again.', + NO_ACTIVE_COMBAT: + '๐ŸŒฟ **No enemy in sight.** Use `/explore` to find your next encounter.', + API_UNAVAILABLE: + '๐Ÿ”ง **The realm is under maintenance.** The game server is temporarily unreachable. Please try again in a moment.', + API_TIMEOUT: + 'โณ **The winds of fate are slow today.** The game server took too long to respond. Please try again.', + API_NETWORK_ERROR: + '๐ŸŒ **Lost connection to the realm.** Could not reach the game server. Please try again later.', // Raw API error strings (legacy matching) - 'You are incapacitated.': '๐Ÿ’€ **You have fallen!** Your wounds are too severe. Rest and recover before venturing out again.', - 'You are incapacitated. Wait for regeneration.': '๐Ÿ’€ **You have fallen!** Wait for your health to regenerate before venturing out.', - 'No active combat found.': '๐ŸŒฟ **No enemy in sight.** Use `/explore` to find your next encounter.', - 'You are currently in combat!': 'โš”๏ธ **You\'re already in battle!** Use `/attack` to fight or `/flee` to escape.', - 'Player not found': '๐Ÿ“œ **Adventurer not found!** Begin your journey with `/register`.', - 'Player load failed': '๐Ÿ“œ **Adventurer not found!** Begin your journey with `/register`.', - 'You need to create player data in order to explore!': '๐Ÿ“œ **Adventurer not found!** Begin your journey with `/register`.', - 'Item not found': '๐Ÿ” **That item doesn\'t exist.** Check the ID and try again.', - 'Not enough items': '๐ŸŽ’ **Not enough items!** You don\'t have that many in your inventory.', - 'Cannot sell a locked item.': '๐Ÿ”’ **This item is locked!** Unlock it first before selling.', - 'This item cannot be consumed': 'โŒ **This item can\'t be consumed.** Only consumable items have effects.', - 'You already have player data!': 'โœ… **You\'re already registered!** Use `/profile` to see your character.', + 'You are incapacitated.': + '๐Ÿ’€ **You have fallen!** Your wounds are too severe. Rest and recover before venturing out again.', + 'You are incapacitated. Wait for regeneration.': + '๐Ÿ’€ **You have fallen!** Wait for your health to regenerate before venturing out.', + 'No active combat found.': + '๐ŸŒฟ **No enemy in sight.** Use `/explore` to find your next encounter.', + 'You are currently in combat!': + "โš”๏ธ **You're already in battle!** Use `/attack` to fight or `/flee` to escape.", + 'Player not found': + '๐Ÿ“œ **Adventurer not found!** Begin your journey with `/register`.', + 'Player load failed': + '๐Ÿ“œ **Adventurer not found!** Begin your journey with `/register`.', + 'You need to create player data in order to explore!': + '๐Ÿ“œ **Adventurer not found!** Begin your journey with `/register`.', + 'Item not found': + "๐Ÿ” **That item doesn't exist.** Check the ID and try again.", + 'Not enough items': + "๐ŸŽ’ **Not enough items!** You don't have that many in your inventory.", + 'Cannot sell a locked item.': + '๐Ÿ”’ **This item is locked!** Unlock it first before selling.', + 'This item cannot be consumed': + "โŒ **This item can't be consumed.** Only consumable items have effects.", + 'You already have player data!': + "โœ… **You're already registered!** Use `/profile` to see your character." }; /** @@ -52,15 +71,20 @@ export function formatError(error: string, code?: string): string { * Formats a 429 cooldown response with a proper Discord timestamp. * @param cooldownRemainingMs - milliseconds remaining (from API), or null for a generic message */ -export function formatCooldown(action: 'step' | 'combat', cooldownRemainingMs?: number): string { +export function formatCooldown( + action: 'step' | 'combat', + cooldownRemainingMs?: number +): string { if (cooldownRemainingMs) { - const futureTimestamp = Math.floor(Date.now() / 1000) + Math.ceil(cooldownRemainingMs / 1000); + const futureTimestamp = + Math.floor(Date.now() / 1000) + Math.ceil(cooldownRemainingMs / 1000); if (action === 'step') { return `โณ **Recovering...** You can explore again .`; } return `โณ **Weapon cooling down!** You can attack again .`; } - if (action === 'step') return 'โณ **Recovering...** Please wait before exploring again.'; + if (action === 'step') + return 'โณ **Recovering...** Please wait before exploring again.'; return 'โณ **Weapon cooling down!** Please wait before attacking again.'; -} \ No newline at end of file +} diff --git a/src/utilities/ImageService.ts b/src/utilities/ImageService.ts index 820da33..2904e1d 100644 --- a/src/utilities/ImageService.ts +++ b/src/utilities/ImageService.ts @@ -1,72 +1,91 @@ -import { User } from 'discord.js'; -import { IStepJSON } from '../interfaces/IStepJSON'; -import { ICombatJSON } from '../interfaces/ICombatJSON'; -import { IPlayerJSON } from '../interfaces/IPlayerJSON'; -import { IItemJSON } from '../interfaces/IItemJSON'; -import { IInventoryItem } from '../interfaces/IInventoryJSON'; -import { ITaskJSON, IChestSlot } from '../interfaces/IGameJSON'; -import ItemManager from '../managers/ItemManager'; -import WorkerPool from './WorkerPool'; -import type { LeaderboardEntry, LeaderboardConfig } from './LeaderboardImageBuilder'; +import { type User } from 'discord.js'; +import { type IStepJSON } from '../interfaces/IStepJSON'; +import { type ICombatJSON } from '../interfaces/ICombatJSON'; +import { type IPlayerJSON } from '../interfaces/IPlayerJSON'; +import { type IItemJSON } from '../interfaces/IItemJSON'; +import { type IInventoryItem } from '../interfaces/IInventoryJSON'; +import type { ITaskJSON, IChestSlot } from '../interfaces/IGameJSON'; +import * as ItemManager from '../managers/ItemManager'; +import * as WorkerPool from './WorkerPool'; +import type { + LeaderboardEntry, + LeaderboardConfig +} from './LeaderboardImageBuilder'; import type { MarketListing, MarketPageConfig } from './MarketImageBuilder'; import type { TasksPageConfig } from './TasksImageBuilder'; import type { ChestsPageConfig } from './ChestsImageBuilder'; -/** - * High-level image generation API. - * Routes all canvas work through the WorkerPool. - */ -export default class ImageService { - - private static serializeItemCache(): Record { - const cache: Record = {}; - for (const [id, item] of ItemManager.cache) { - cache[id] = item; - } - return cache; +function serializeItemCache(): Record { + const cache: Record = {}; + for (const [id, item] of ItemManager.cache) { + cache[id] = item; } + return cache; +} - public static adventure(data: IStepJSON | ICombatJSON): Promise { - return WorkerPool.run('adventure', { data }); - } +export async function adventure( + data: IStepJSON | ICombatJSON +): Promise { + return WorkerPool.run('adventure', { data }); +} - public static profile(player: IPlayerJSON, discordUser: User): Promise { - return WorkerPool.run('profile', { - player, - avatarUrl: discordUser.displayAvatarURL({ extension: 'png', size: 256 }), - itemCache: this.serializeItemCache(), - }); - } +export async function profile( + player: IPlayerJSON, + discordUser: User +): Promise { + return WorkerPool.run('profile', { + player, + avatarUrl: discordUser.displayAvatarURL({ extension: 'png', size: 256 }), + itemCache: serializeItemCache() + }); +} - public static inventory(chunk: IInventoryItem[], player: IPlayerJSON): Promise { - return WorkerPool.run('inventory', { - chunk, - player, - itemCache: this.serializeItemCache(), - }); - } +export async function inventory( + chunk: IInventoryItem[], + player: IPlayerJSON +): Promise { + return WorkerPool.run('inventory', { + chunk, + player, + itemCache: serializeItemCache() + }); +} - public static item(itemData: IItemJSON): Promise { - return WorkerPool.run('item', { item: itemData }); - } +export async function item(itemData: IItemJSON): Promise { + return WorkerPool.run('item', { item: itemData }); +} - public static leaderboard(entries: LeaderboardEntry[], config: LeaderboardConfig): Promise { - return WorkerPool.run('leaderboard', { entries, config }); - } +export async function leaderboard( + entries: LeaderboardEntry[], + config: LeaderboardConfig +): Promise { + return WorkerPool.run('leaderboard', { entries, config }); +} - public static market(listings: MarketListing[], config: MarketPageConfig): Promise { - return WorkerPool.run('market', { listings, config }); - } +export async function market( + listings: MarketListing[], + config: MarketPageConfig +): Promise { + return WorkerPool.run('market', { listings, config }); +} - public static travel(playerLevel: number, currentZoneId: number): Promise { - return WorkerPool.run('travel', { playerLevel, currentZoneId }); - } +export async function travel( + playerLevel: number, + currentZoneId: number +): Promise { + return WorkerPool.run('travel', { playerLevel, currentZoneId }); +} - public static tasks(tasks: ITaskJSON[], config: TasksPageConfig): Promise { - return WorkerPool.run('tasks', { tasks, config }); - } +export async function tasks( + tasks: ITaskJSON[], + config: TasksPageConfig +): Promise { + return WorkerPool.run('tasks', { tasks, config }); +} - public static chests(chests: IChestSlot[], config: ChestsPageConfig): Promise { - return WorkerPool.run('chests', { chests, config }); - } +export async function chests( + chests: IChestSlot[], + config: ChestsPageConfig +): Promise { + return WorkerPool.run('chests', { chests, config }); } diff --git a/src/utilities/ImageWorker.ts b/src/utilities/ImageWorker.ts index 32e6f28..4df8b5a 100644 --- a/src/utilities/ImageWorker.ts +++ b/src/utilities/ImageWorker.ts @@ -1,13 +1,13 @@ import { parentPort } from 'worker_threads'; -import AdventureImageBuilder from './AdventureImageBuilder'; -import ProfileImageBuilder from './ProfileImageBuilder'; -import InventoryImageBuilder from './InventoryImageBuilder'; -import ItemImageBuilder from './ItemImageBuilder'; -import LeaderboardImageBuilder from './LeaderboardImageBuilder'; -import MarketImageBuilder from './MarketImageBuilder'; -import TravelImageBuilder from './TravelImageBuilder'; -import TasksImageBuilder from './TasksImageBuilder'; -import ChestsImageBuilder from './ChestsImageBuilder'; +import * as AdventureImageBuilder from './AdventureImageBuilder'; +import * as ProfileImageBuilder from './ProfileImageBuilder'; +import * as InventoryImageBuilder from './InventoryImageBuilder'; +import * as ItemImageBuilder from './ItemImageBuilder'; +import * as LeaderboardImageBuilder from './LeaderboardImageBuilder'; +import * as MarketImageBuilder from './MarketImageBuilder'; +import * as TravelImageBuilder from './TravelImageBuilder'; +import * as TasksImageBuilder from './TasksImageBuilder'; +import * as ChestsImageBuilder from './ChestsImageBuilder'; if (!parentPort) { throw new Error('ImageWorker must be run as a worker thread'); @@ -18,65 +18,79 @@ parentPort.on('message', async (msg: { builderName: string; payload: any }) => { let buffer: Buffer; switch (msg.builderName) { - case 'adventure': - buffer = await AdventureImageBuilder.build(msg.payload.data); - break; + case 'adventure': + buffer = await AdventureImageBuilder.build(msg.payload.data); + break; - case 'profile': - buffer = await ProfileImageBuilder.build( - msg.payload.player, - msg.payload.avatarUrl, - msg.payload.itemCache - ); - break; + case 'profile': + buffer = await ProfileImageBuilder.build( + msg.payload.player, + msg.payload.avatarUrl, + msg.payload.itemCache + ); + break; - case 'inventory': - buffer = await InventoryImageBuilder.build( - msg.payload.chunk, - msg.payload.player, - msg.payload.itemCache - ); - break; + case 'inventory': + buffer = await InventoryImageBuilder.build( + msg.payload.chunk, + msg.payload.player, + msg.payload.itemCache + ); + break; - case 'item': - buffer = await ItemImageBuilder.build(msg.payload.item); - break; + case 'item': + buffer = await ItemImageBuilder.build(msg.payload.item); + break; - case 'leaderboard': - buffer = await LeaderboardImageBuilder.build(msg.payload.entries, msg.payload.config); - break; + case 'leaderboard': + buffer = await LeaderboardImageBuilder.build( + msg.payload.entries, + msg.payload.config + ); + break; - case 'market': - buffer = await MarketImageBuilder.build(msg.payload.listings, msg.payload.config); - break; + case 'market': + buffer = await MarketImageBuilder.build( + msg.payload.listings, + msg.payload.config + ); + break; - case 'travel': - buffer = await TravelImageBuilder.build(msg.payload.playerLevel, msg.payload.currentZoneId); - break; + case 'travel': + buffer = await TravelImageBuilder.build( + msg.payload.playerLevel, + msg.payload.currentZoneId + ); + break; - case 'tasks': - buffer = await TasksImageBuilder.build(msg.payload.tasks, msg.payload.config); - break; + case 'tasks': + buffer = await TasksImageBuilder.build( + msg.payload.tasks, + msg.payload.config + ); + break; - case 'chests': - buffer = await ChestsImageBuilder.build(msg.payload.chests, msg.payload.config); - break; + case 'chests': + buffer = await ChestsImageBuilder.build( + msg.payload.chests, + msg.payload.config + ); + break; - default: - throw new Error(`Unknown builder: ${msg.builderName}`); + default: + throw new Error(`Unknown builder: ${msg.builderName}`); } const arrayBuffer = new ArrayBuffer(buffer.byteLength); new Uint8Array(arrayBuffer).set(buffer); - parentPort!.postMessage( - { success: true, buffer: arrayBuffer }, - [arrayBuffer] - ); + parentPort!.postMessage({ success: true, buffer: arrayBuffer }, [ + arrayBuffer + ]); } catch (err: any) { parentPort!.postMessage({ success: false, - error: err.message ?? String(err), + error: err.message ?? String(err) }); } }); diff --git a/src/utilities/InventoryImageBuilder.ts b/src/utilities/InventoryImageBuilder.ts index b5586ab..f50d549 100644 --- a/src/utilities/InventoryImageBuilder.ts +++ b/src/utilities/InventoryImageBuilder.ts @@ -1,192 +1,224 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; -import { IInventoryItem } from '../interfaces/IInventoryJSON'; -import { IPlayerJSON } from '../interfaces/IPlayerJSON'; -import { IItemJSON } from '../interfaces/IItemJSON'; -import ItemManager from '../managers/ItemManager'; +import { type IInventoryItem } from '../interfaces/IInventoryJSON'; +import { type IPlayerJSON } from '../interfaces/IPlayerJSON'; +import { type IItemJSON } from '../interfaces/IItemJSON'; +import * as ItemManager from '../managers/ItemManager'; import { join } from 'path'; -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} const RARITY_COLORS: Record = { - Common: '#b0b0b0', Uncommon: '#2ecc71', Rare: '#3498db', - Elite: '#e67e22', Epic: '#9b59b6', Legendary: '#f1c40f', - Divine: '#00e5ff', Exotic: '#ff00cc' + Common: '#b0b0b0', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff', + Exotic: '#ff00cc' }; const SLOT_ICONS: Record = { - 'Head': 'โ›‘๏ธ', 'Necklace': '๐Ÿ“ฟ', 'Chest': '๐Ÿ‘•', 'MainHand': 'โš”๏ธ', - 'Legs': '๐Ÿ‘–', 'OffHand': '๐Ÿ›ก๏ธ', 'Hands': '๐Ÿงค', 'RingA': '๐Ÿ’', - 'RingB': '๐Ÿ’', 'Feet': '๐Ÿ‘ข', 'Pet': '๐Ÿพ', 'Special': 'โœจ' + Head: 'โ›‘๏ธ', + Necklace: '๐Ÿ“ฟ', + Chest: '๐Ÿ‘•', + MainHand: 'โš”๏ธ', + Legs: '๐Ÿ‘–', + OffHand: '๐Ÿ›ก๏ธ', + Hands: '๐Ÿงค', + RingA: '๐Ÿ’', + RingB: '๐Ÿ’', + Feet: '๐Ÿ‘ข', + Pet: '๐Ÿพ', + Special: 'โœจ' }; const CATEGORY_ICONS: Record = { - 'Weapon': 'โš”๏ธ', 'Armor': '๐Ÿ›ก๏ธ', 'Accessory': '๐Ÿ’', - 'Consumable': '๐Ÿงช', 'Material': '๐Ÿชต', 'Collectible': '๐Ÿ—ฟ' + Weapon: 'โš”๏ธ', + Armor: '๐Ÿ›ก๏ธ', + Accessory: '๐Ÿ’', + Consumable: '๐Ÿงช', + Material: '๐Ÿชต', + Collectible: '๐Ÿ—ฟ' }; -function getItemIcon(item: any) { - if (item.slot && SLOT_ICONS[item.slot]) return SLOT_ICONS[item.slot]; - return CATEGORY_ICONS[item.type] || '๐Ÿ“ฆ'; +function getItemIcon(item: any): string { + if (item.slot && SLOT_ICONS[item.slot]) return SLOT_ICONS[item.slot]; + return CATEGORY_ICONS[item.type] || '๐Ÿ“ฆ'; } -export default class InventoryImageBuilder { - public static async build( - chunk: IInventoryItem[], - player: IPlayerJSON, - itemCache?: Record - ): Promise { - const getItem = (id: number): IItemJSON | undefined => { - if (itemCache) return itemCache[id]; - return ItemManager.get(id); - }; - - const canvas = createCanvas(900, 720); - const ctx = canvas.getContext('2d'); - - // Background - ctx.fillStyle = '#111111'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const bgGradient = ctx.createLinearGradient(0, 0, 0, 200); - bgGradient.addColorStop(0, '#1a1a1a'); - bgGradient.addColorStop(1, '#111111'); - ctx.fillStyle = bgGradient; - ctx.fillRect(0, 0, canvas.width, 200); - - // Header - ctx.fillStyle = '#ffffff'; - ctx.textAlign = 'left'; - ctx.font = '32px "NotoEmoji", sans-serif'; - ctx.fillText('๐Ÿ’ผ', 40, 60); - ctx.font = 'bold 32px sans-serif'; - ctx.fillText(`${player.username.toUpperCase()}'S INVENTORY`, 85, 60, 500); - - ctx.fillStyle = '#10b981'; - ctx.font = 'bold 14px sans-serif'; - ctx.textAlign = 'right'; - const goldFormatted = new Intl.NumberFormat('en-US').format(player.coins || 0); - ctx.fillText(`LVL ${player.level} โ€ข ${goldFormatted} GOLD`, 860, 55); - - // Divider +export async function build( + chunk: IInventoryItem[], + player: IPlayerJSON, + itemCache?: Record +): Promise { + const getItem = (id: number): IItemJSON | undefined => { + if (itemCache) return itemCache[id]; + return ItemManager.get(id); + }; + + const canvas = createCanvas(900, 720); + const ctx = canvas.getContext('2d'); + + // Background + ctx.fillStyle = '#111111'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const bgGradient = ctx.createLinearGradient(0, 0, 0, 200); + bgGradient.addColorStop(0, '#1a1a1a'); + bgGradient.addColorStop(1, '#111111'); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, canvas.width, 200); + + // Header + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'left'; + ctx.font = '32px "NotoEmoji", sans-serif'; + ctx.fillText('๐Ÿ’ผ', 40, 60); + ctx.font = 'bold 32px sans-serif'; + ctx.fillText(`${player.username.toUpperCase()}'S INVENTORY`, 85, 60, 500); + + ctx.fillStyle = '#10b981'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'right'; + const goldFormatted = new Intl.NumberFormat('en-US').format( + player.coins || 0 + ); + ctx.fillText(`LVL ${player.level} โ€ข ${goldFormatted} GOLD`, 860, 55); + + // Divider + ctx.beginPath(); + ctx.moveTo(40, 80); + ctx.lineTo(860, 80); + ctx.lineWidth = 1; + ctx.strokeStyle = '#ffffff1a'; + ctx.stroke(); + + // Grid (5 cols x 3 rows = 15 items) + const startX = 40; + const startY = 110; + const boxW = 150; + const boxH = 180; + const gapX = 17.5; + const gapY = 20; + + for (let i = 0; i < chunk.length; i++) { + const invEntry = chunk[i]; + const itemData = getItem(invEntry.itemId); + + const col = i % 5; + const row = Math.floor(i / 5); + const boxX = startX + col * (boxW + gapX); + const boxY = startY + row * (boxH + gapY); + + // Box BG + ctx.fillStyle = '#00000066'; ctx.beginPath(); - ctx.moveTo(40, 80); - ctx.lineTo(860, 80); + ctx.roundRect(boxX, boxY, boxW, boxH, 12); + ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = '#ffffff1a'; ctx.stroke(); - // Grid (5 cols x 3 rows = 15 items) - const startX = 40; - const startY = 110; - const boxW = 150; - const boxH = 180; - const gapX = 17.5; - const gapY = 20; - - for (let i = 0; i < chunk.length; i++) { - const invEntry = chunk[i]; - const itemData = getItem(invEntry.itemId); - - const col = i % 5; - const row = Math.floor(i / 5); - const boxX = startX + col * (boxW + gapX); - const boxY = startY + row * (boxH + gapY); - - // Box BG - ctx.fillStyle = '#00000066'; + if (itemData) { + const color = RARITY_COLORS[itemData.rarity] || '#ffffff'; + const enhanceLevel = invEntry.enhanceLevel || 0; + + // Top Left: Lock Icon + if (invEntry.isLocked) { + ctx.fillStyle = '#ffffff'; + ctx.font = '14px "NotoEmoji", sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('๐Ÿ”’', boxX + 10, boxY + 25); + } + + // Top Left (after lock): Enhancement Badge + if (enhanceLevel > 0) { + const badgeX = invEntry.isLocked ? boxX + 30 : boxX + 8; + const badgeText = `+${enhanceLevel}`; + ctx.font = 'bold 10px sans-serif'; + const badgeW = ctx.measureText(badgeText).width + 8; + + ctx.fillStyle = '#92400e88'; // amber-900/50 ctx.beginPath(); - ctx.roundRect(boxX, boxY, boxW, boxH, 12); + ctx.roundRect(badgeX, boxY + 10, badgeW, 16, 3); ctx.fill(); + ctx.strokeStyle = '#f59e0b66'; ctx.lineWidth = 1; - ctx.strokeStyle = '#ffffff1a'; ctx.stroke(); - if (itemData) { - const color = RARITY_COLORS[itemData.rarity] || '#ffffff'; - const enhanceLevel = invEntry.enhanceLevel || 0; - - // Top Left: Lock Icon - if (invEntry.isLocked) { - ctx.fillStyle = '#ffffff'; - ctx.font = '14px "NotoEmoji", sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText('๐Ÿ”’', boxX + 10, boxY + 25); - } - - // Top Left (after lock): Enhancement Badge - if (enhanceLevel > 0) { - const badgeX = invEntry.isLocked ? boxX + 30 : boxX + 8; - const badgeText = `+${enhanceLevel}`; - ctx.font = 'bold 10px sans-serif'; - const badgeW = ctx.measureText(badgeText).width + 8; - - ctx.fillStyle = '#92400e88'; // amber-900/50 - ctx.beginPath(); - ctx.roundRect(badgeX, boxY + 10, badgeW, 16, 3); - ctx.fill(); - ctx.strokeStyle = '#f59e0b66'; - ctx.lineWidth = 1; - ctx.stroke(); - - ctx.fillStyle = '#fbbf24'; - ctx.textAlign = 'center'; - ctx.fillText(badgeText, badgeX + badgeW / 2, boxY + 22); - } - - // Top Right: Quantity Pill - ctx.fillStyle = '#00000099'; - ctx.beginPath(); - ctx.roundRect(boxX + boxW - 40, boxY + 10, 30, 18, 4); - ctx.fill(); - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 10px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(`x${invEntry.quantity}`, boxX + boxW - 25, boxY + 23); - - // Center: Emoji - ctx.fillStyle = '#ffffff'; - ctx.font = '45px "NotoEmoji", sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(getItemIcon(itemData), boxX + boxW / 2, boxY + 85); - - // Bottom Panel BG - ctx.fillStyle = '#00000099'; - ctx.beginPath(); - ctx.roundRect(boxX, boxY + 110, boxW, 70, [0, 0, 12, 12]); - ctx.fill(); - - // Item Name (with +level suffix if enhanced) - ctx.fillStyle = color; - ctx.font = 'bold 12px sans-serif'; - const displayName = enhanceLevel > 0 ? `${itemData.name} +${enhanceLevel}` : itemData.name; - ctx.fillText(displayName, boxX + boxW / 2, boxY + 132, boxW - 10); - - // Type & Level - ctx.fillStyle = '#6b7280'; - ctx.font = '10px sans-serif'; - ctx.fillText(`${itemData.type.toUpperCase()} | LVL ${itemData.level}`, boxX + boxW / 2, boxY + 148); - - // Value - ctx.fillStyle = '#eab308'; - ctx.font = '10px sans-serif'; - const totalValue = Math.floor((itemData.value || 0) * invEntry.quantity); - ctx.fillText(`${totalValue.toLocaleString()}g`, boxX + boxW / 2, boxY + 164); - - // Bottom Rarity Border - ctx.beginPath(); - ctx.moveTo(boxX + 10, boxY + boxH); - ctx.lineTo(boxX + boxW - 10, boxY + boxH); - ctx.lineWidth = 4; - ctx.strokeStyle = color; - ctx.stroke(); - } else { - ctx.fillStyle = '#374151'; - ctx.font = 'italic 12px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('Unknown Item', boxX + boxW / 2, boxY + boxH / 2); - } + ctx.fillStyle = '#fbbf24'; + ctx.textAlign = 'center'; + ctx.fillText(badgeText, badgeX + badgeW / 2, boxY + 22); + } + + // Top Right: Quantity Pill + ctx.fillStyle = '#00000099'; + ctx.beginPath(); + ctx.roundRect(boxX + boxW - 40, boxY + 10, 30, 18, 4); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(`x${invEntry.quantity}`, boxX + boxW - 25, boxY + 23); + + // Center: Emoji + ctx.fillStyle = '#ffffff'; + ctx.font = '45px "NotoEmoji", sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(getItemIcon(itemData), boxX + boxW / 2, boxY + 85); + + // Bottom Panel BG + ctx.fillStyle = '#00000099'; + ctx.beginPath(); + ctx.roundRect(boxX, boxY + 110, boxW, 70, [0, 0, 12, 12]); + ctx.fill(); + + // Item Name (with +level suffix if enhanced) + ctx.fillStyle = color; + ctx.font = 'bold 12px sans-serif'; + const displayName = + enhanceLevel > 0 ? `${itemData.name} +${enhanceLevel}` : itemData.name; + ctx.fillText(displayName, boxX + boxW / 2, boxY + 132, boxW - 10); + + // Type & Level + ctx.fillStyle = '#6b7280'; + ctx.font = '10px sans-serif'; + ctx.fillText( + `${itemData.type.toUpperCase()} | LVL ${itemData.level}`, + boxX + boxW / 2, + boxY + 148 + ); + + // Value + ctx.fillStyle = '#eab308'; + ctx.font = '10px sans-serif'; + const totalValue = Math.floor((itemData.value || 0) * invEntry.quantity); + ctx.fillText( + `${totalValue.toLocaleString()}g`, + boxX + boxW / 2, + boxY + 164 + ); + + // Bottom Rarity Border + ctx.beginPath(); + ctx.moveTo(boxX + 10, boxY + boxH); + ctx.lineTo(boxX + boxW - 10, boxY + boxH); + ctx.lineWidth = 4; + ctx.strokeStyle = color; + ctx.stroke(); + } else { + ctx.fillStyle = '#374151'; + ctx.font = 'italic 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Unknown Item', boxX + boxW / 2, boxY + boxH / 2); } - - return canvas.toBuffer('image/png'); } -} \ No newline at end of file + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/ItemImageBuilder.ts b/src/utilities/ItemImageBuilder.ts index 5573145..5a54ac8 100644 --- a/src/utilities/ItemImageBuilder.ts +++ b/src/utilities/ItemImageBuilder.ts @@ -1,223 +1,259 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; -import { IItemJSON } from '../interfaces/IItemJSON'; +import { type IItemJSON } from '../interfaces/IItemJSON'; import { join } from 'path'; -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} const RARITY_COLORS: Record = { - Common: '#b0b0b0', Uncommon: '#2ecc71', Rare: '#3498db', - Elite: '#e67e22', Epic: '#9b59b6', Legendary: '#f1c40f', - Divine: '#00e5ff', Exotic: '#ff00cc' + Common: '#b0b0b0', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff', + Exotic: '#ff00cc' }; const SLOT_ICONS: Record = { - 'Head': 'โ›‘๏ธ', 'Necklace': '๐Ÿ“ฟ', 'Chest': '๐Ÿ‘•', 'MainHand': 'โš”๏ธ', - 'Legs': '๐Ÿ‘–', 'OffHand': '๐Ÿ›ก๏ธ', 'Hands': '๐Ÿงค', 'RingA': '๐Ÿ’', - 'RingB': '๐Ÿ’', 'Feet': '๐Ÿ‘ข', 'Pet': '๐Ÿพ', 'Special': 'โœจ' + Head: 'โ›‘๏ธ', + Necklace: '๐Ÿ“ฟ', + Chest: '๐Ÿ‘•', + MainHand: 'โš”๏ธ', + Legs: '๐Ÿ‘–', + OffHand: '๐Ÿ›ก๏ธ', + Hands: '๐Ÿงค', + RingA: '๐Ÿ’', + RingB: '๐Ÿ’', + Feet: '๐Ÿ‘ข', + Pet: '๐Ÿพ', + Special: 'โœจ' }; const CATEGORY_ICONS: Record = { - 'Weapon': 'โš”๏ธ', 'Armor': '๐Ÿ›ก๏ธ', 'Accessory': '๐Ÿ’', - 'Consumable': '๐Ÿงช', 'Material': '๐Ÿชต', 'Collectible': '๐Ÿ—ฟ' + Weapon: 'โš”๏ธ', + Armor: '๐Ÿ›ก๏ธ', + Accessory: '๐Ÿ’', + Consumable: '๐Ÿงช', + Material: '๐Ÿชต', + Collectible: '๐Ÿ—ฟ' }; -function getItemIcon(item: IItemJSON) { - if (item.slot && item.slot !== 'None' && SLOT_ICONS[item.slot]) return SLOT_ICONS[item.slot]; - return CATEGORY_ICONS[item.type] || '๐Ÿ“ฆ'; +function getItemIcon(item: IItemJSON): string { + if (item.slot && item.slot !== 'None' && SLOT_ICONS[item.slot]) + return SLOT_ICONS[item.slot]; + return CATEGORY_ICONS[item.type] || '๐Ÿ“ฆ'; } -export default class ItemImageBuilder { - public static async build(item: IItemJSON): Promise { - const affixesCount = item.affixes?.length || 0; - const enhanceLevel = (item as any).enhanceLevel || 0; - const canvasHeight = affixesCount > 0 ? 430 + (affixesCount * 45) : 400; - - const canvas = createCanvas(600, canvasHeight); - const ctx = canvas.getContext('2d'); - const color = RARITY_COLORS[item.rarity] || '#ffffff'; - - // 1. Background & Rarity Glow - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - ctx.lineWidth = 4; - ctx.strokeStyle = `${color}44`; - ctx.strokeRect(2, 2, canvas.width - 4, canvas.height - 4); - - const bgGradient = ctx.createLinearGradient(0, 0, 0, 150); - bgGradient.addColorStop(0, `${color}11`); - bgGradient.addColorStop(1, '#0a0a0a00'); - ctx.fillStyle = bgGradient; - ctx.fillRect(0, 0, canvas.width, 150); - - // 2. Icon & Name - ctx.fillStyle = '#ffffff'; - ctx.font = '60px "NotoEmoji", sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(getItemIcon(item), canvas.width / 2, 85); - - // Item name with enhance level - const displayName = enhanceLevel > 0 ? `${item.name} +${enhanceLevel}` : item.name; - ctx.fillStyle = color; - ctx.font = 'bold 32px sans-serif'; - ctx.fillText(displayName, canvas.width / 2, 135, 560); - - // 3. Badges (Rarity, Type, and Enhancement) - ctx.font = 'bold 10px sans-serif'; - const rarityText = item.rarity.toUpperCase(); - let typeText = item.type.toUpperCase(); - if (item.slot && item.slot !== 'None') typeText += ` โ€ข ${item.slot.toUpperCase()}`; - - const rWidth = ctx.measureText(rarityText).width + 20; - const tWidth = ctx.measureText(typeText).width + 20; - - // Enhancement badge dimensions - let enhText = ''; - let enhWidth = 0; - if (enhanceLevel > 0) { - enhText = `+${enhanceLevel} ENHANCED`; - enhWidth = ctx.measureText(enhText).width + 20; - } - - const totalBadgeWidth = rWidth + tWidth + (enhWidth > 0 ? enhWidth + 10 : 0) + 10; - let currentX = (canvas.width - totalBadgeWidth) / 2; +export async function build(item: IItemJSON): Promise { + const affixesCount = item.affixes?.length || 0; + const enhanceLevel = (item as any).enhanceLevel || 0; + const canvasHeight = affixesCount > 0 ? 430 + affixesCount * 45 : 400; + + const canvas = createCanvas(600, canvasHeight); + const ctx = canvas.getContext('2d'); + const color = RARITY_COLORS[item.rarity] || '#ffffff'; + + // 1. Background & Rarity Glow + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.lineWidth = 4; + ctx.strokeStyle = `${color}44`; + ctx.strokeRect(2, 2, canvas.width - 4, canvas.height - 4); + + const bgGradient = ctx.createLinearGradient(0, 0, 0, 150); + bgGradient.addColorStop(0, `${color}11`); + bgGradient.addColorStop(1, '#0a0a0a00'); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, canvas.width, 150); + + // 2. Icon & Name + ctx.fillStyle = '#ffffff'; + ctx.font = '60px "NotoEmoji", sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(getItemIcon(item), canvas.width / 2, 85); + + // Item name with enhance level + const displayName = + enhanceLevel > 0 ? `${item.name} +${enhanceLevel}` : item.name; + ctx.fillStyle = color; + ctx.font = 'bold 32px sans-serif'; + ctx.fillText(displayName, canvas.width / 2, 135, 560); + + // 3. Badges (Rarity, Type, and Enhancement) + ctx.font = 'bold 10px sans-serif'; + const rarityText = item.rarity.toUpperCase(); + let typeText = item.type.toUpperCase(); + if (item.slot && item.slot !== 'None') + typeText += ` โ€ข ${item.slot.toUpperCase()}`; + + const rWidth = ctx.measureText(rarityText).width + 20; + const tWidth = ctx.measureText(typeText).width + 20; + + // Enhancement badge dimensions + let enhText = ''; + let enhWidth = 0; + if (enhanceLevel > 0) { + enhText = `+${enhanceLevel} ENHANCED`; + enhWidth = ctx.measureText(enhText).width + 20; + } - // Rarity Badge - ctx.fillStyle = `${color}1a`; - ctx.strokeStyle = `${color}66`; + const totalBadgeWidth = + rWidth + tWidth + (enhWidth > 0 ? enhWidth + 10 : 0) + 10; + let currentX = (canvas.width - totalBadgeWidth) / 2; + + // Rarity Badge + ctx.fillStyle = `${color}1a`; + ctx.strokeStyle = `${color}66`; + ctx.beginPath(); + ctx.roundRect(currentX, 155, rWidth, 24, 4); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = color; + ctx.fillText(rarityText, currentX + rWidth / 2, 171); + + currentX += rWidth + 10; + + // Type/Slot Badge + ctx.fillStyle = '#ffffff0a'; + ctx.strokeStyle = '#ffffff20'; + ctx.beginPath(); + ctx.roundRect(currentX, 155, tWidth, 24, 4); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = '#9ca3af'; + ctx.fillText(typeText, currentX + tWidth / 2, 171); + + // Enhancement Badge (amber) + if (enhanceLevel > 0) { + currentX += tWidth + 10; + ctx.fillStyle = '#92400e44'; // amber-900/25 + ctx.strokeStyle = '#f59e0b66'; // amber-500/40 ctx.beginPath(); - ctx.roundRect(currentX, 155, rWidth, 24, 4); + ctx.roundRect(currentX, 155, enhWidth, 24, 4); ctx.fill(); ctx.stroke(); - ctx.fillStyle = color; - ctx.fillText(rarityText, currentX + rWidth / 2, 171); - - currentX += rWidth + 10; + ctx.fillStyle = '#fbbf24'; // amber-400 + ctx.fillText(enhText, currentX + enhWidth / 2, 171); + } - // Type/Slot Badge - ctx.fillStyle = '#ffffff0a'; - ctx.strokeStyle = '#ffffff20'; + // 4. Description + ctx.fillStyle = '#9ca3af'; + ctx.font = 'italic 16px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(`"${item.description}"`, canvas.width / 2, 220, 540); + + // 5. Stats or Consumable Effect + let yOffset = 260; + if (item.type === 'Consumable') { + ctx.fillStyle = '#713f1233'; + ctx.strokeStyle = '#eab3084d'; ctx.beginPath(); - ctx.roundRect(currentX, 155, tWidth, 24, 4); + ctx.roundRect(50, yOffset, 500, 70, 8); ctx.fill(); ctx.stroke(); - ctx.fillStyle = '#9ca3af'; - ctx.fillText(typeText, currentX + tWidth / 2, 171); - - // Enhancement Badge (amber) - if (enhanceLevel > 0) { - currentX += tWidth + 10; - ctx.fillStyle = '#92400e44'; // amber-900/25 - ctx.strokeStyle = '#f59e0b66'; // amber-500/40 - ctx.beginPath(); - ctx.roundRect(currentX, 155, enhWidth, 24, 4); - ctx.fill(); - ctx.stroke(); - ctx.fillStyle = '#fbbf24'; // amber-400 - ctx.fillText(enhText, currentX + enhWidth / 2, 171); - } - // 4. Description - ctx.fillStyle = '#9ca3af'; - ctx.font = 'italic 16px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(`"${item.description}"`, canvas.width / 2, 220, 540); - - // 5. Stats or Consumable Effect - let yOffset = 260; - if (item.type === 'Consumable') { - ctx.fillStyle = '#713f1233'; - ctx.strokeStyle = '#eab3084d'; - ctx.beginPath(); - ctx.roundRect(50, yOffset, 500, 70, 8); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = '#eab308'; - ctx.font = 'bold 10px sans-serif'; - ctx.fillText('EFFECT', canvas.width / 2, yOffset + 25); - - let effectText = 'Unknown Effect'; - let effectColor = '#ffffff'; - if (item.action?.effect === 'HEAL_HP') { effectText = `Restores ${item.action.amount} HP`; effectColor = '#4ade80'; } - else if (item.action?.effect === 'GRANT_XP') { effectText = `Grants ${item.action.amount} XP`; effectColor = '#c084fc'; } - else if (item.action?.effect === 'GRANT_GOLD') { effectText = `Grants ${item.action.amount} Gold`; effectColor = '#fbbf24'; } - - ctx.fillStyle = effectColor; - ctx.font = 'bold 20px sans-serif'; - ctx.fillText(effectText, canvas.width / 2, yOffset + 50); - - } else if (item.stats) { - const boxW = 150; - const gap = 20; - const statX = (canvas.width - ((boxW * 3) + (gap * 2))) / 2; - - const drawStatBox = (x: number, label: string, val: number, valColor: string) => { - ctx.fillStyle = '#ffffff0a'; - ctx.strokeStyle = '#ffffff1a'; - ctx.beginPath(); - ctx.roundRect(x, yOffset, boxW, 70, 8); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = '#6b7280'; - ctx.font = 'bold 10px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(label, x + boxW / 2, yOffset + 25); - - ctx.fillStyle = valColor; - ctx.font = 'bold 24px monospace'; - ctx.fillText(val.toString(), x + boxW / 2, yOffset + 55); - }; - - drawStatBox(statX, 'ATK', item.stats.atk || 0, '#f87171'); - drawStatBox(statX + boxW + gap, 'DEF', item.stats.def || 0, '#60a5fa'); - drawStatBox(statX + (boxW + gap) * 2, 'HP', item.stats.hp || 0, '#4ade80'); + ctx.fillStyle = '#eab308'; + ctx.font = 'bold 10px sans-serif'; + ctx.fillText('EFFECT', canvas.width / 2, yOffset + 25); + + let effectText = 'Unknown Effect'; + let effectColor = '#ffffff'; + if (item.action?.effect === 'HEAL_HP') { + effectText = `Restores ${item.action.amount} HP`; + effectColor = '#4ade80'; + } else if (item.action?.effect === 'GRANT_XP') { + effectText = `Grants ${item.action.amount} XP`; + effectColor = '#c084fc'; + } else if (item.action?.effect === 'GRANT_GOLD') { + effectText = `Grants ${item.action.amount} Gold`; + effectColor = '#fbbf24'; } - // 6. Affixes - if (affixesCount > 0) { - yOffset += 110; - - ctx.fillStyle = '#c084fc'; - ctx.font = 'bold 10px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('SPECIAL EFFECTS', canvas.width / 2, yOffset); - - yOffset += 15; - item.affixes!.forEach(affix => { - ctx.fillStyle = '#581c8733'; - ctx.strokeStyle = '#a855f733'; - ctx.beginPath(); - ctx.roundRect(150, yOffset, 300, 32, 4); - ctx.fill(); - ctx.stroke(); - - ctx.fillStyle = '#e9d5ff'; - ctx.font = 'bold 12px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(affix.type.replace('_', ' '), 165, yOffset + 21); - - const valText = `+${affix.value}${affix.type === 'THORNS' ? '' : '%'}`; - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 14px monospace'; - ctx.textAlign = 'right'; - ctx.fillText(valText, 435, yOffset + 22); - - yOffset += 40; - }); - } + ctx.fillStyle = effectColor; + ctx.font = 'bold 20px sans-serif'; + ctx.fillText(effectText, canvas.width / 2, yOffset + 50); + } else if (item.stats) { + const boxW = 150; + const gap = 20; + const statX = (canvas.width - (boxW * 3 + gap * 2)) / 2; + + const drawStatBox = ( + x: number, + label: string, + val: number, + valColor: string + ): void => { + ctx.fillStyle = '#ffffff0a'; + ctx.strokeStyle = '#ffffff1a'; + ctx.beginPath(); + ctx.roundRect(x, yOffset, boxW, 70, 8); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = '#6b7280'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(label, x + boxW / 2, yOffset + 25); + + ctx.fillStyle = valColor; + ctx.font = 'bold 24px monospace'; + ctx.fillText(val.toString(), x + boxW / 2, yOffset + 55); + }; + + drawStatBox(statX, 'ATK', item.stats.atk || 0, '#f87171'); + drawStatBox(statX + boxW + gap, 'DEF', item.stats.def || 0, '#60a5fa'); + drawStatBox(statX + (boxW + gap) * 2, 'HP', item.stats.hp || 0, '#4ade80'); + } - // 7. Footer - ctx.fillStyle = '#4b5563'; - ctx.font = '10px monospace'; - ctx.textAlign = 'center'; - - let footerText = `ID: ${item.itemId}`; - if (item.level > 1) footerText += ` | REQ LVL: ${item.level}`; - - ctx.fillText(footerText, canvas.width / 2, canvas.height - 20); + // 6. Affixes + if (affixesCount > 0) { + yOffset += 110; - return canvas.toBuffer('image/png'); + ctx.fillStyle = '#c084fc'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('SPECIAL EFFECTS', canvas.width / 2, yOffset); + + yOffset += 15; + item.affixes!.forEach((affix) => { + ctx.fillStyle = '#581c8733'; + ctx.strokeStyle = '#a855f733'; + ctx.beginPath(); + ctx.roundRect(150, yOffset, 300, 32, 4); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = '#e9d5ff'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(affix.type.replace('_', ' '), 165, yOffset + 21); + + const valText = `+${affix.value}${affix.type === 'THORNS' ? '' : '%'}`; + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(valText, 435, yOffset + 22); + + yOffset += 40; + }); } + + // 7. Footer + ctx.fillStyle = '#4b5563'; + ctx.font = '10px monospace'; + ctx.textAlign = 'center'; + + let footerText = `ID: ${item.itemId}`; + if (item.level > 1) footerText += ` | REQ LVL: ${item.level}`; + + ctx.fillText(footerText, canvas.width / 2, canvas.height - 20); + + return canvas.toBuffer('image/png'); } diff --git a/src/utilities/ItemViewBuilder.ts b/src/utilities/ItemViewBuilder.ts index c316e89..de73fae 100644 --- a/src/utilities/ItemViewBuilder.ts +++ b/src/utilities/ItemViewBuilder.ts @@ -1,9 +1,15 @@ -import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; -import { IInventoryItem } from "../interfaces/IInventoryJSON"; -import ItemManager from "../managers/ItemManager"; -import ImageService from "./ImageService"; -import { IPlayerJSON } from "../interfaces/IPlayerJSON"; -import { IItemJSON } from "../interfaces/IItemJSON"; +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + type EmbedBuilder +} from 'discord.js'; +import { type IInventoryItem } from '../interfaces/IInventoryJSON'; +import * as ItemManager from '../managers/ItemManager'; +import * as ImageService from './ImageService'; +import { type IPlayerJSON } from '../interfaces/IPlayerJSON'; +import { type IItemJSON } from '../interfaces/IItemJSON'; export interface ItemViewResponse { embeds: EmbedBuilder[]; @@ -15,7 +21,10 @@ export interface ItemViewResponse { * Builds the single-item detail view with action buttons. * All buttons encode the inventory document _id for variant-safe operations. */ -export async function buildItemView(player: IPlayerJSON, item: IInventoryItem): Promise { +export async function buildItemView( + player: IPlayerJSON, + item: IInventoryItem +): Promise { const hydratedItem = ItemManager.get(item.itemId); if (!hydratedItem || !item || !player) { @@ -34,41 +43,56 @@ export async function buildItemView(player: IPlayerJSON, item: IInventoryItem): (displayItem as any).enhanceLevel = item.enhanceLevel || 0; const buffer = await ImageService.item(displayItem); - const attachment = new AttachmentBuilder(buffer, { name: `${hydratedItem.itemId}.png` }); + const attachment = new AttachmentBuilder(buffer, { + name: `${hydratedItem.itemId}.png` + }); const isWithinLevel = player.level >= hydratedItem.level; const hasSlot = hydratedItem.slot !== 'None'; const isConsumable = hydratedItem.type === 'Consumable'; const isLocked = item.isLocked; - const isModified = (item.enhanceLevel > 0) || !!item.statOverrides || !!item.affixOverrides; + const isModified = + item.enhanceLevel > 0 || !!item.statOverrides || !!item.affixOverrides; const docId = item._id; // MongoDB document _id for variant targeting // === ROW 1: Equip / Consume + Lock === let equipText = 'Equip'; - if (!isWithinLevel) equipText = `Required Level: ${hydratedItem.level.toLocaleString()}`; + if (!isWithinLevel) + equipText = `Required Level: ${hydratedItem.level.toLocaleString()}`; if (!hasSlot) equipText = 'Cannot Equip'; if (isLocked) equipText = '๐Ÿ”’ Locked Item'; let equipDisabled = !isWithinLevel || !hasSlot || isLocked; let equipStyle = equipDisabled ? ButtonStyle.Secondary : ButtonStyle.Primary; - if (isConsumable) { equipDisabled = false; equipStyle = ButtonStyle.Primary; } + if (isConsumable) { + equipDisabled = false; + equipStyle = ButtonStyle.Primary; + } const equipButton = new ButtonBuilder() - .setCustomId(isConsumable ? `consume:${docId}:${item.quantity}` : `equip:${docId}:${item.itemId}`) + .setCustomId( + isConsumable + ? `consume:${docId}:${item.quantity}` + : `equip:${docId}:${item.itemId}` + ) .setLabel(isConsumable ? 'Consume' : equipText) - .setDisabled(equipDisabled).setStyle(equipStyle); + .setDisabled(equipDisabled) + .setStyle(equipStyle); const lockButton = new ButtonBuilder() .setCustomId(`lock:${docId}:${item.isLocked ? '1' : '0'}`) .setLabel(item.isLocked ? '๐Ÿ”“ Unlock' : '๐Ÿ”’ Lock') .setStyle(item.isLocked ? ButtonStyle.Success : ButtonStyle.Danger); - const row1 = new ActionRowBuilder().setComponents(equipButton, lockButton); + const row1 = new ActionRowBuilder().setComponents( + equipButton, + lockButton + ); // === ROW 2: Sell + Collect === // Modified items can't be vendor-sold or collected let sellText = `๐Ÿช™ Sell (${Math.floor(hydratedItem.value * item.quantity).toLocaleString()}g)`; - let sellDisabled = isConsumable || isLocked; + const sellDisabled = isConsumable || isLocked; let collectText = 'Add to Collection'; let collectDisabled = isConsumable || isLocked; @@ -83,18 +107,33 @@ export async function buildItemView(player: IPlayerJSON, item: IInventoryItem): } const sellButton = new ButtonBuilder() - .setCustomId(isModified && !isLocked ? `market_redirect:${item.itemId}` : `sell:${docId}:${item.quantity}`) + .setCustomId( + isModified && !isLocked + ? `market_redirect:${item.itemId}` + : `sell:${docId}:${item.quantity}` + ) .setLabel(sellText) - .setStyle(isConsumable || isLocked || isModified ? ButtonStyle.Secondary : ButtonStyle.Success) + .setStyle( + isConsumable || isLocked || isModified + ? ButtonStyle.Secondary + : ButtonStyle.Success + ) .setDisabled(sellDisabled && !isModified); const collectButton = new ButtonBuilder() .setCustomId(`collect:${docId}:${item.quantity}`) .setLabel(collectText) - .setStyle(isConsumable || isLocked || isModified ? ButtonStyle.Secondary : ButtonStyle.Primary) + .setStyle( + isConsumable || isLocked || isModified + ? ButtonStyle.Secondary + : ButtonStyle.Primary + ) .setDisabled(collectDisabled); - const row2 = new ActionRowBuilder().setComponents(sellButton, collectButton); + const row2 = new ActionRowBuilder().setComponents( + sellButton, + collectButton + ); // === ROW 3: Workshop (Enhance / Reforge / Dismantle) โ€” non-consumable only === const rows: ActionRowBuilder[] = [row1, row2]; @@ -102,7 +141,9 @@ export async function buildItemView(player: IPlayerJSON, item: IInventoryItem): if (!isConsumable && hasSlot) { const enhanceButton = new ButtonBuilder() .setCustomId(`enhance:${docId}:${item.itemId}`) - .setLabel(`โฌ†๏ธ Enhance${item.enhanceLevel > 0 ? ` (+${item.enhanceLevel})` : ''}`) + .setLabel( + `โฌ†๏ธ Enhance${item.enhanceLevel > 0 ? ` (+${item.enhanceLevel})` : ''}` + ) .setStyle(ButtonStyle.Primary) .setDisabled(isLocked); @@ -118,7 +159,11 @@ export async function buildItemView(player: IPlayerJSON, item: IInventoryItem): .setStyle(ButtonStyle.Danger) .setDisabled(isLocked); - const row3 = new ActionRowBuilder().setComponents(enhanceButton, reforgeButton, dismantleButton); + const row3 = new ActionRowBuilder().setComponents( + enhanceButton, + reforgeButton, + dismantleButton + ); rows.push(row3); } else if (!isConsumable && !hasSlot) { // Items without a slot (like materials) can still be dismantled @@ -128,9 +173,11 @@ export async function buildItemView(player: IPlayerJSON, item: IInventoryItem): .setStyle(ButtonStyle.Danger) .setDisabled(isLocked); - const row3 = new ActionRowBuilder().setComponents(dismantleButton); + const row3 = new ActionRowBuilder().setComponents( + dismantleButton + ); rows.push(row3); } return { embeds: [], files: [attachment], components: rows }; -} \ No newline at end of file +} diff --git a/src/utilities/LeaderboardImageBuilder.ts b/src/utilities/LeaderboardImageBuilder.ts index e83e98b..e502871 100644 --- a/src/utilities/LeaderboardImageBuilder.ts +++ b/src/utilities/LeaderboardImageBuilder.ts @@ -2,7 +2,12 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; import { join } from 'path'; // Load emoji font -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} export interface LeaderboardEntry { username: string; @@ -14,8 +19,8 @@ export interface LeaderboardConfig { title: string; stat: string; emoji: string; - accentColor: string; // hex, e.g. '#eab308' - accentColorDim: string; // hex with opacity, e.g. '#eab30833' + accentColor: string; // hex, e.g. '#eab308' + accentColorDim: string; // hex with opacity, e.g. '#eab30833' } const MEDAL_COLORS = ['#fbbf24', '#c0c0c0', '#cd7f32']; // Gold, Silver, Bronze @@ -25,114 +30,131 @@ const FOOTER_HEIGHT = 50; const PADDING = 40; const CANVAS_WIDTH = 800; -export default class LeaderboardImageBuilder { - public static async build(entries: LeaderboardEntry[], config: LeaderboardConfig): Promise { - const rowCount = Math.min(entries.length, 10); - const canvasHeight = HEADER_HEIGHT + (rowCount * ROW_HEIGHT) + FOOTER_HEIGHT + PADDING; - - const canvas = createCanvas(CANVAS_WIDTH, canvasHeight); - const ctx = canvas.getContext('2d'); - const contentWidth = CANVAS_WIDTH - (PADDING * 2); - - // --- 1. Background --- - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Subtle scanlines - ctx.strokeStyle = '#ffffff05'; - ctx.lineWidth = 1; - for (let i = 0; i < canvas.height; i += 20) { - ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(canvas.width, i); ctx.stroke(); - } +export async function build( + entries: LeaderboardEntry[], + config: LeaderboardConfig +): Promise { + const rowCount = Math.min(entries.length, 10); + const canvasHeight = + HEADER_HEIGHT + rowCount * ROW_HEIGHT + FOOTER_HEIGHT + PADDING; + + const canvas = createCanvas(CANVAS_WIDTH, canvasHeight); + const ctx = canvas.getContext('2d'); + const contentWidth = CANVAS_WIDTH - PADDING * 2; + + // --- 1. Background --- + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Subtle scanlines + ctx.strokeStyle = '#ffffff05'; + ctx.lineWidth = 1; + for (let i = 0; i < canvas.height; i += 20) { + ctx.beginPath(); + ctx.moveTo(0, i); + ctx.lineTo(canvas.width, i); + ctx.stroke(); + } - // Top accent gradient - const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_HEIGHT); - headerGrad.addColorStop(0, config.accentColorDim); - headerGrad.addColorStop(1, '#0a0a0a00'); - ctx.fillStyle = headerGrad; - ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); - - // --- 2. Header --- - ctx.textAlign = 'center'; - - // Emoji - ctx.font = '32px "NotoEmoji", sans-serif'; - ctx.fillStyle = '#ffffff'; - ctx.fillText(config.emoji, canvas.width / 2, 45); - - // Title - ctx.fillStyle = config.accentColor; - ctx.font = 'bold 28px sans-serif'; - ctx.fillText(config.title, canvas.width / 2, 82); - - // Subtitle - ctx.fillStyle = '#6b7280'; - ctx.font = '14px sans-serif'; - ctx.fillText(`Top ${rowCount} Players โ€” Ranked by ${config.stat}`, canvas.width / 2, 105); - - // --- 3. Rows --- - const startY = HEADER_HEIGHT + 10; - - for (let i = 0; i < rowCount; i++) { - const entry = entries[i]; - const rowY = startY + (i * ROW_HEIGHT); - const isTop3 = i < 3; - - // Row background โ€” alternating subtle stripes - ctx.fillStyle = i % 2 === 0 ? '#ffffff06' : '#00000000'; + // Top accent gradient + const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_HEIGHT); + headerGrad.addColorStop(0, config.accentColorDim); + headerGrad.addColorStop(1, '#0a0a0a00'); + ctx.fillStyle = headerGrad; + ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); + + // --- 2. Header --- + ctx.textAlign = 'center'; + + // Emoji + ctx.font = '32px "NotoEmoji", sans-serif'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(config.emoji, canvas.width / 2, 45); + + // Title + ctx.fillStyle = config.accentColor; + ctx.font = 'bold 28px sans-serif'; + ctx.fillText(config.title, canvas.width / 2, 82); + + // Subtitle + ctx.fillStyle = '#6b7280'; + ctx.font = '14px sans-serif'; + ctx.fillText( + `Top ${rowCount} Players โ€” Ranked by ${config.stat}`, + canvas.width / 2, + 105 + ); + + // --- 3. Rows --- + const startY = HEADER_HEIGHT + 10; + + for (let i = 0; i < rowCount; i++) { + const entry = entries[i]; + const rowY = startY + i * ROW_HEIGHT; + const isTop3 = i < 3; + + // Row background โ€” alternating subtle stripes + ctx.fillStyle = i % 2 === 0 ? '#ffffff06' : '#00000000'; + ctx.beginPath(); + ctx.roundRect(PADDING, rowY, contentWidth, ROW_HEIGHT - 4, 8); + ctx.fill(); + + // Top 3 get a colored left accent bar + if (isTop3) { + ctx.fillStyle = MEDAL_COLORS[i]; ctx.beginPath(); - ctx.roundRect(PADDING, rowY, contentWidth, ROW_HEIGHT - 4, 8); + ctx.roundRect(PADDING, rowY, 4, ROW_HEIGHT - 4, [4, 0, 0, 4]); ctx.fill(); + } - // Top 3 get a colored left accent bar - if (isTop3) { - ctx.fillStyle = MEDAL_COLORS[i]; - ctx.beginPath(); - ctx.roundRect(PADDING, rowY, 4, ROW_HEIGHT - 4, [4, 0, 0, 4]); - ctx.fill(); - } - - // Rank number - const rankX = PADDING + 30; - if (isTop3) { - // Medal emoji for top 3 - const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; - ctx.font = '24px "NotoEmoji", sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(medals[i], rankX, rowY + 40); - } else { - ctx.fillStyle = '#4b5563'; - ctx.font = 'bold 20px monospace'; - ctx.textAlign = 'center'; - ctx.fillText(`${i + 1}`, rankX, rowY + 40); - } - - // Username - ctx.textAlign = 'left'; - ctx.fillStyle = isTop3 ? '#ffffff' : '#d1d5db'; - ctx.font = `${isTop3 ? 'bold ' : ''}18px sans-serif`; - ctx.fillText(entry.username, PADDING + 65, rowY + 34); - - // Level badge (small, under the name) - ctx.fillStyle = '#374151'; - ctx.font = '11px sans-serif'; - ctx.fillText(`LVL ${entry.level}`, PADDING + 65, rowY + 52); - - // Stat value โ€” right aligned - ctx.textAlign = 'right'; - ctx.fillStyle = isTop3 ? config.accentColor : '#9ca3af'; - ctx.font = `bold ${isTop3 ? '22' : '18'}px monospace`; - ctx.fillText(entry.value.toLocaleString(), PADDING + contentWidth - 15, rowY + 40); + // Rank number + const rankX = PADDING + 30; + if (isTop3) { + // Medal emoji for top 3 + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + ctx.font = '24px "NotoEmoji", sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText(medals[i], rankX, rowY + 40); + } else { + ctx.fillStyle = '#4b5563'; + ctx.font = 'bold 20px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(`${i + 1}`, rankX, rowY + 40); } - // --- 4. Footer --- - const footerY = startY + (rowCount * ROW_HEIGHT) + 15; + // Username + ctx.textAlign = 'left'; + ctx.fillStyle = isTop3 ? '#ffffff' : '#d1d5db'; + ctx.font = `${isTop3 ? 'bold ' : ''}18px sans-serif`; + ctx.fillText(entry.username, PADDING + 65, rowY + 34); - ctx.textAlign = 'center'; + // Level badge (small, under the name) ctx.fillStyle = '#374151'; ctx.font = '11px sans-serif'; - ctx.fillText('โš”๏ธ DFO Cross-Platform Integration โ€” capi.gg', canvas.width / 2, footerY); - - return canvas.toBuffer('image/png'); + ctx.fillText(`LVL ${entry.level}`, PADDING + 65, rowY + 52); + + // Stat value โ€” right aligned + ctx.textAlign = 'right'; + ctx.fillStyle = isTop3 ? config.accentColor : '#9ca3af'; + ctx.font = `bold ${isTop3 ? '22' : '18'}px monospace`; + ctx.fillText( + entry.value.toLocaleString(), + PADDING + contentWidth - 15, + rowY + 40 + ); } -} \ No newline at end of file + + // --- 4. Footer --- + const footerY = startY + rowCount * ROW_HEIGHT + 15; + + ctx.textAlign = 'center'; + ctx.fillStyle = '#374151'; + ctx.font = '11px sans-serif'; + ctx.fillText( + 'โš”๏ธ DFO Cross-Platform Integration โ€” capi.gg', + canvas.width / 2, + footerY + ); + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/Logger.ts b/src/utilities/Logger.ts index ad834b2..beefe2d 100644 --- a/src/utilities/Logger.ts +++ b/src/utilities/Logger.ts @@ -18,12 +18,16 @@ const additionalLevels = { dev: 35, command: 34, player: 33, - button: 32, + button: 32 }; // --- File destination (reopenable for rotation) --- // minLength: 0 ensures writes flush quickly โ€” prevents sonic-boom "not ready" on exit -let fileDestination = pino.destination({ dest: LOG_FILE, sync: false, minLength: 0 }); +const fileDestination = pino.destination({ + dest: LOG_FILE, + sync: false, + minLength: 0 +}); /** * Rotates log files when bot.log exceeds MAX_SIZE_BYTES. @@ -50,7 +54,9 @@ function rotateIfNeeded(): void { renameSync(LOG_FILE, join(LOG_DIR, 'bot.1.log')); fileDestination.reopen(); - logger.info(`[Logger] Log rotated. Previous file exceeded ${MAX_SIZE_BYTES / 1024 / 1024}MB`); + logger.info( + `[Logger] Log rotated. Previous file exceeded ${MAX_SIZE_BYTES / 1024 / 1024}MB` + ); } catch (err) { console.error('[Logger] Rotation failed:', err); } @@ -60,7 +66,7 @@ function rotateIfNeeded(): void { const logger = pino( { customLevels: additionalLevels, - level: 'debug', + level: 'debug' }, pino.multistream([ { @@ -73,15 +79,16 @@ const logger = pino( ignore: 'pid,hostname', levelFirst: true, customLevels: 'dev:35,command:34,player:33,button:32', - customColors: 'dev:magenta,command:magenta,player:magenta,button:magenta', - useOnlyCustomProps: false, - }, - }), + customColors: + 'dev:magenta,command:magenta,player:magenta,button:magenta', + useOnlyCustomProps: false + } + }) }, { level: 'debug', - stream: fileDestination, - }, + stream: fileDestination + } ]) ); @@ -103,7 +110,15 @@ export function flushAndClose(): void { } // Safely handle exit โ€” wrap in try-catch to silence sonic-boom errors -process.on('beforeExit', () => { try { fileDestination.flushSync(); } catch {} }); -process.on('exit', () => { try { fileDestination.flushSync(); } catch {} }); +process.on('beforeExit', () => { + try { + fileDestination.flushSync(); + } catch {} +}); +process.on('exit', () => { + try { + fileDestination.flushSync(); + } catch {} +}); export default logger; diff --git a/src/utilities/MarketImageBuilder.ts b/src/utilities/MarketImageBuilder.ts index 7d54da4..b69a2ff 100644 --- a/src/utilities/MarketImageBuilder.ts +++ b/src/utilities/MarketImageBuilder.ts @@ -1,27 +1,51 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; import { join } from 'path'; -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} const RARITY_COLORS: Record = { - Common: '#b0b0b0', Uncommon: '#2ecc71', Rare: '#3498db', - Elite: '#e67e22', Epic: '#9b59b6', Legendary: '#f1c40f', - Divine: '#00e5ff', Exotic: '#ff00cc' + Common: '#b0b0b0', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff', + Exotic: '#ff00cc' }; const SLOT_ICONS: Record = { - 'Head': 'โ›‘๏ธ', 'Necklace': '๐Ÿ“ฟ', 'Chest': '๐Ÿ‘•', 'MainHand': 'โš”๏ธ', - 'Legs': '๐Ÿ‘–', 'OffHand': '๐Ÿ›ก๏ธ', 'Hands': '๐Ÿงค', 'RingA': '๐Ÿ’', - 'RingB': '๐Ÿ’', 'Feet': '๐Ÿ‘ข', 'Pet': '๐Ÿพ', 'Special': 'โœจ' + Head: 'โ›‘๏ธ', + Necklace: '๐Ÿ“ฟ', + Chest: '๐Ÿ‘•', + MainHand: 'โš”๏ธ', + Legs: '๐Ÿ‘–', + OffHand: '๐Ÿ›ก๏ธ', + Hands: '๐Ÿงค', + RingA: '๐Ÿ’', + RingB: '๐Ÿ’', + Feet: '๐Ÿ‘ข', + Pet: '๐Ÿพ', + Special: 'โœจ' }; const CATEGORY_ICONS: Record = { - 'Weapon': 'โš”๏ธ', 'Armor': '๐Ÿ›ก๏ธ', 'Accessory': '๐Ÿ’', - 'Consumable': '๐Ÿงช', 'Material': '๐Ÿชต', 'Collectible': '๐Ÿ—ฟ' + Weapon: 'โš”๏ธ', + Armor: '๐Ÿ›ก๏ธ', + Accessory: '๐Ÿ’', + Consumable: '๐Ÿงช', + Material: '๐Ÿชต', + Collectible: '๐Ÿ—ฟ' }; function getItemIcon(item: any): string { - if (item.slot && item.slot !== 'None' && SLOT_ICONS[item.slot]) return SLOT_ICONS[item.slot]; + if (item.slot && item.slot !== 'None' && SLOT_ICONS[item.slot]) + return SLOT_ICONS[item.slot]; return CATEGORY_ICONS[item.type] || '๐Ÿ“ฆ'; } @@ -54,142 +78,170 @@ const FOOTER_HEIGHT = 45; const PADDING = 30; const CANVAS_WIDTH = 850; -export default class MarketImageBuilder { - public static async build(listings: MarketListing[], config: MarketPageConfig): Promise { - const rowCount = listings.length; - const canvasHeight = HEADER_HEIGHT + Math.max(rowCount, 1) * ROW_HEIGHT + FOOTER_HEIGHT + PADDING; +export async function build( + listings: MarketListing[], + config: MarketPageConfig +): Promise { + const rowCount = listings.length; + const canvasHeight = + HEADER_HEIGHT + + Math.max(rowCount, 1) * ROW_HEIGHT + + FOOTER_HEIGHT + + PADDING; + + const canvas = createCanvas(CANVAS_WIDTH, canvasHeight); + const ctx = canvas.getContext('2d'); + const contentWidth = CANVAS_WIDTH - PADDING * 2; + + // --- Background --- + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = '#ffffff05'; + ctx.lineWidth = 1; + for (let i = 0; i < canvas.height; i += 20) { + ctx.beginPath(); + ctx.moveTo(0, i); + ctx.lineTo(canvas.width, i); + ctx.stroke(); + } - const canvas = createCanvas(CANVAS_WIDTH, canvasHeight); - const ctx = canvas.getContext('2d'); - const contentWidth = CANVAS_WIDTH - (PADDING * 2); + const accentColor = config.mode === 'my_listings' ? '#3b82f6' : '#10b981'; + const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_HEIGHT); + headerGrad.addColorStop(0, `${accentColor}25`); + headerGrad.addColorStop(1, '#0a0a0a00'); + ctx.fillStyle = headerGrad; + ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); + + // --- Header (text only, no emoji overlap) --- + ctx.textAlign = 'center'; + ctx.fillStyle = accentColor; + ctx.font = 'bold 28px sans-serif'; + ctx.fillText( + config.mode === 'my_listings' ? 'My Listings' : 'Global Market', + canvas.width / 2, + 42 + ); + + ctx.fillStyle = '#6b7280'; + ctx.font = '13px sans-serif'; + ctx.fillText( + `${config.totalItems.toLocaleString()} listing${config.totalItems !== 1 ? 's' : ''} โ€” Page ${config.page} of ${Math.max(1, config.totalPages)}`, + canvas.width / 2, + 68 + ); + + // --- Empty state --- + const startY = HEADER_HEIGHT; + + if (rowCount === 0) { + ctx.fillStyle = '#4b5563'; + ctx.font = 'italic 18px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText( + config.mode === 'my_listings' + ? 'You have no active listings.' + : 'No listings found.', + canvas.width / 2, + startY + 36 + ); + } - // --- Background --- - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + // --- Listing rows --- + for (let i = 0; i < rowCount; i++) { + const listing = listings[i]; + const item = listing.item; + const rowY = startY + i * ROW_HEIGHT; + const rarityColor = RARITY_COLORS[item.rarity] || '#ffffff'; + const displayIndex = i + 1; + + // Row bg + ctx.fillStyle = i % 2 === 0 ? '#ffffff06' : '#00000000'; + ctx.beginPath(); + ctx.roundRect(PADDING, rowY, contentWidth, ROW_HEIGHT - 4, 8); + ctx.fill(); + + // Rarity accent bar + ctx.fillStyle = rarityColor; + ctx.beginPath(); + ctx.roundRect(PADDING, rowY, 4, ROW_HEIGHT - 4, [4, 0, 0, 4]); + ctx.fill(); + + // --- Number badge --- + ctx.fillStyle = '#ffffff12'; + ctx.beginPath(); + ctx.roundRect(PADDING + 14, rowY + 18, 32, 32, 6); + ctx.fill(); - ctx.strokeStyle = '#ffffff05'; - ctx.lineWidth = 1; - for (let i = 0; i < canvas.height; i += 20) { - ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(canvas.width, i); ctx.stroke(); - } + ctx.fillStyle = accentColor; + ctx.font = 'bold 18px monospace'; + ctx.textAlign = 'center'; + ctx.fillText(`${displayIndex}`, PADDING + 30, rowY + 40); - const accentColor = config.mode === 'my_listings' ? '#3b82f6' : '#10b981'; - const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_HEIGHT); - headerGrad.addColorStop(0, accentColor + '25'); - headerGrad.addColorStop(1, '#0a0a0a00'); - ctx.fillStyle = headerGrad; - ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); + // Item icon + ctx.font = '22px "NotoEmoji", sans-serif'; + ctx.fillStyle = '#ffffff'; + ctx.fillText(getItemIcon(item), PADDING + 70, rowY + 42); - // --- Header (text only, no emoji overlap) --- - ctx.textAlign = 'center'; - ctx.fillStyle = accentColor; - ctx.font = 'bold 28px sans-serif'; - ctx.fillText(config.mode === 'my_listings' ? 'My Listings' : 'Global Market', canvas.width / 2, 42); + // Item name + ctx.textAlign = 'left'; + ctx.fillStyle = rarityColor; + ctx.font = 'bold 16px sans-serif'; + ctx.fillText(item.name, PADDING + 95, rowY + 28, 300); + // Meta line ctx.fillStyle = '#6b7280'; - ctx.font = '13px sans-serif'; + ctx.font = '11px sans-serif'; + let meta = `${item.rarity} ${item.type} โ€ข Lvl ${item.level}`; + if (config.mode === 'browse') meta += ` โ€ข by ${listing.sellerName}`; + ctx.fillText(meta, PADDING + 95, rowY + 46, 300); + + // Quantity pill + const qtyText = `x${listing.quantity}`; + ctx.font = 'bold 12px sans-serif'; + const qtyWidth = ctx.measureText(qtyText).width + 16; + const qtyX = PADDING + contentWidth - 215; + + ctx.fillStyle = '#ffffff0f'; + ctx.beginPath(); + ctx.roundRect(qtyX, rowY + 20, qtyWidth, 22, 4); + ctx.fill(); + + ctx.fillStyle = '#d1d5db'; + ctx.textAlign = 'center'; + ctx.fillText(qtyText, qtyX + qtyWidth / 2, rowY + 35); + + // Price + ctx.textAlign = 'right'; + ctx.font = 'bold 18px monospace'; + ctx.fillStyle = '#fbbf24'; ctx.fillText( - `${config.totalItems.toLocaleString()} listing${config.totalItems !== 1 ? 's' : ''} โ€” Page ${config.page} of ${Math.max(1, config.totalPages)}`, - canvas.width / 2, 68 + `${listing.pricePerUnit.toLocaleString()}g`, + PADDING + contentWidth - 12, + rowY + 34 ); - // --- Empty state --- - const startY = HEADER_HEIGHT; - - if (rowCount === 0) { - ctx.fillStyle = '#4b5563'; - ctx.font = 'italic 18px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText( - config.mode === 'my_listings' ? 'You have no active listings.' : 'No listings found.', - canvas.width / 2, startY + 36 - ); - } - - // --- Listing rows --- - for (let i = 0; i < rowCount; i++) { - const listing = listings[i]; - const item = listing.item; - const rowY = startY + (i * ROW_HEIGHT); - const rarityColor = RARITY_COLORS[item.rarity] || '#ffffff'; - const displayIndex = i + 1; - - // Row bg - ctx.fillStyle = i % 2 === 0 ? '#ffffff06' : '#00000000'; - ctx.beginPath(); - ctx.roundRect(PADDING, rowY, contentWidth, ROW_HEIGHT - 4, 8); - ctx.fill(); - - // Rarity accent bar - ctx.fillStyle = rarityColor; - ctx.beginPath(); - ctx.roundRect(PADDING, rowY, 4, ROW_HEIGHT - 4, [4, 0, 0, 4]); - ctx.fill(); - - // --- Number badge --- - ctx.fillStyle = '#ffffff12'; - ctx.beginPath(); - ctx.roundRect(PADDING + 14, rowY + 18, 32, 32, 6); - ctx.fill(); - - ctx.fillStyle = accentColor; - ctx.font = 'bold 18px monospace'; - ctx.textAlign = 'center'; - ctx.fillText(`${displayIndex}`, PADDING + 30, rowY + 40); - - // Item icon - ctx.font = '22px "NotoEmoji", sans-serif'; - ctx.fillStyle = '#ffffff'; - ctx.fillText(getItemIcon(item), PADDING + 70, rowY + 42); - - // Item name - ctx.textAlign = 'left'; - ctx.fillStyle = rarityColor; - ctx.font = 'bold 16px sans-serif'; - ctx.fillText(item.name, PADDING + 95, rowY + 28, 300); - - // Meta line + if (listing.quantity > 1) { ctx.fillStyle = '#6b7280'; ctx.font = '11px sans-serif'; - let meta = `${item.rarity} ${item.type} โ€ข Lvl ${item.level}`; - if (config.mode === 'browse') meta += ` โ€ข by ${listing.sellerName}`; - ctx.fillText(meta, PADDING + 95, rowY + 46, 300); - - // Quantity pill - const qtyText = `x${listing.quantity}`; - ctx.font = 'bold 12px sans-serif'; - const qtyWidth = ctx.measureText(qtyText).width + 16; - const qtyX = PADDING + contentWidth - 215; - - ctx.fillStyle = '#ffffff0f'; - ctx.beginPath(); - ctx.roundRect(qtyX, rowY + 20, qtyWidth, 22, 4); - ctx.fill(); - - ctx.fillStyle = '#d1d5db'; - ctx.textAlign = 'center'; - ctx.fillText(qtyText, qtyX + qtyWidth / 2, rowY + 35); - - // Price - ctx.textAlign = 'right'; - ctx.font = 'bold 18px monospace'; - ctx.fillStyle = '#fbbf24'; - ctx.fillText(`${listing.pricePerUnit.toLocaleString()}g`, PADDING + contentWidth - 12, rowY + 34); - - if (listing.quantity > 1) { - ctx.fillStyle = '#6b7280'; - ctx.font = '11px sans-serif'; - ctx.fillText(`Total: ${(listing.pricePerUnit * listing.quantity).toLocaleString()}g`, PADDING + contentWidth - 12, rowY + 52); - } + ctx.fillText( + `Total: ${(listing.pricePerUnit * listing.quantity).toLocaleString()}g`, + PADDING + contentWidth - 12, + rowY + 52 + ); } - - // --- Footer --- - const footerY = startY + Math.max(rowCount, 1) * ROW_HEIGHT + 12; - ctx.textAlign = 'center'; - ctx.fillStyle = '#374151'; - ctx.font = '11px sans-serif'; - ctx.fillText('โš”๏ธ DFO Cross-Platform Market โ€” capi.gg', canvas.width / 2, footerY); - - return canvas.toBuffer('image/png'); } -} \ No newline at end of file + + // --- Footer --- + const footerY = startY + Math.max(rowCount, 1) * ROW_HEIGHT + 12; + ctx.textAlign = 'center'; + ctx.fillStyle = '#374151'; + ctx.font = '11px sans-serif'; + ctx.fillText( + 'โš”๏ธ DFO Cross-Platform Market โ€” capi.gg', + canvas.width / 2, + footerY + ); + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/PaginatorBuilder.ts b/src/utilities/PaginatorBuilder.ts index baffa25..41a5065 100644 --- a/src/utilities/PaginatorBuilder.ts +++ b/src/utilities/PaginatorBuilder.ts @@ -3,17 +3,18 @@ import { ButtonBuilder, ButtonStyle, EmbedBuilder, - CommandInteraction, - MessageComponentInteraction, + type CommandInteraction, + type MessageComponentInteraction, ComponentType, - AttachmentBuilder, - MessageActionRowComponentBuilder + type AttachmentBuilder, + type MessageActionRowComponentBuilder } from 'discord.js'; export default class PaginatorBuilder { private pages: EmbedBuilder[] = []; private files: AttachmentBuilder[] = []; - private extraRows: ActionRowBuilder[][] = []; + private extraRows: ActionRowBuilder[][] = + []; private idleTimeout: number = 60000; private targetUserId: string | null = null; private isEphemeral: boolean = false; @@ -38,7 +39,9 @@ export default class PaginatorBuilder { * Indexed per page โ€” extraRows[0] = rows for page 0, etc. * Discord max 5 rows total; pagination takes 1, so up to 4 extra per page. */ - public setExtraRows(rows: ActionRowBuilder[][]): this { + public setExtraRows( + rows: ActionRowBuilder[][] + ): this { this.extraRows = rows; return this; } @@ -58,19 +61,35 @@ export default class PaginatorBuilder { return this; } - public async start(interaction: CommandInteraction | MessageComponentInteraction): Promise { + public async start( + interaction: CommandInteraction | MessageComponentInteraction + ): Promise { if (this.pages.length === 0) { - throw new Error('[PaginatorBuilder] Cannot start a paginator with 0 pages.'); + throw new Error( + '[PaginatorBuilder] Cannot start a paginator with 0 pages.' + ); } let currentPage = 0; - const firstBtn = new ButtonBuilder().setCustomId('page_first').setLabel('โช').setStyle(ButtonStyle.Secondary); - const prevBtn = new ButtonBuilder().setCustomId('page_prev').setLabel('โ—€').setStyle(ButtonStyle.Primary); - const nextBtn = new ButtonBuilder().setCustomId('page_next').setLabel('โ–ถ').setStyle(ButtonStyle.Primary); - const lastBtn = new ButtonBuilder().setCustomId('page_last').setLabel('โฉ').setStyle(ButtonStyle.Secondary); - - const getNavRow = (index: number) => { + const firstBtn = new ButtonBuilder() + .setCustomId('page_first') + .setLabel('โช') + .setStyle(ButtonStyle.Secondary); + const prevBtn = new ButtonBuilder() + .setCustomId('page_prev') + .setLabel('โ—€') + .setStyle(ButtonStyle.Primary); + const nextBtn = new ButtonBuilder() + .setCustomId('page_next') + .setLabel('โ–ถ') + .setStyle(ButtonStyle.Primary); + const lastBtn = new ButtonBuilder() + .setCustomId('page_last') + .setLabel('โฉ') + .setStyle(ButtonStyle.Secondary); + + const getNavRow = (index: number): ActionRowBuilder => { return new ActionRowBuilder().addComponents( firstBtn.setDisabled(index === 0), prevBtn.setDisabled(index === 0), @@ -79,7 +98,9 @@ export default class PaginatorBuilder { ); }; - const getComponents = (index: number): ActionRowBuilder[] => { + const getComponents = ( + index: number + ): ActionRowBuilder[] => { const rows: ActionRowBuilder[] = []; const pageExtras = this.extraRows[index] ?? []; rows.push(...pageExtras); @@ -87,12 +108,12 @@ export default class PaginatorBuilder { return rows.slice(0, 5); }; - const getEmbed = (index: number) => { + const getEmbed = (index: number): EmbedBuilder => { const originalEmbed = this.pages[index]; const embed = EmbedBuilder.from(originalEmbed); const currentFooter = originalEmbed.data.footer?.text || ''; - return embed.setFooter({ - text: (currentFooter ? `${currentFooter} | Page ${index + 1} of ${this.pages.length}` : `Page ${index + 1} of ${this.pages.length}`) + ' | โš”๏ธ DFO Cross-Platform', + return embed.setFooter({ + text: `${currentFooter ? `${currentFooter} | Page ${index + 1} of ${this.pages.length}` : `Page ${index + 1} of ${this.pages.length}`} | โš”๏ธ DFO Cross-Platform`, iconURL: originalEmbed.data.footer?.icon_url }); }; @@ -120,7 +141,10 @@ export default class PaginatorBuilder { filter: (i) => { if (!i.customId.startsWith('page_')) return false; if (this.targetUserId && i.user.id !== this.targetUserId) { - i.reply({ content: 'You cannot use these buttons.', ephemeral: true }); + i.reply({ + content: 'You cannot use these buttons.', + ephemeral: true + }); return false; } return true; @@ -130,22 +154,33 @@ export default class PaginatorBuilder { collector.on('collect', async (i) => { collector.resetTimer(); switch (i.customId) { - case 'page_first': currentPage = 0; break; - case 'page_prev': currentPage = Math.max(0, currentPage - 1); break; - case 'page_next': currentPage = Math.min(this.pages.length - 1, currentPage + 1); break; - case 'page_last': currentPage = this.pages.length - 1; break; + case 'page_first': + currentPage = 0; + break; + case 'page_prev': + currentPage = Math.max(0, currentPage - 1); + break; + case 'page_next': + currentPage = Math.min(this.pages.length - 1, currentPage + 1); + break; + case 'page_last': + currentPage = this.pages.length - 1; + break; } await i.update({ embeds: [getEmbed(currentPage)], components: getComponents(currentPage), - files: this.files.length > 0 ? [this.files[currentPage]] : [] + files: this.files.length > 0 ? [this.files[currentPage]] : [] }); }); collector.on('end', async () => { const finalComponents = getComponents(currentPage); - finalComponents.forEach(row => row.components.forEach(c => c.setDisabled(true))); - try { await interaction.editReply({ components: finalComponents }); } catch (e) {} + finalComponents.forEach((row) => row.components.forEach((c) => c.setDisabled(true)) + ); + try { + await interaction.editReply({ components: finalComponents }); + } catch (e) {} }); } -} \ No newline at end of file +} diff --git a/src/utilities/PlayerGuard.ts b/src/utilities/PlayerGuard.ts index 71dc2e6..37d253f 100644 --- a/src/utilities/PlayerGuard.ts +++ b/src/utilities/PlayerGuard.ts @@ -1,46 +1,50 @@ -import { ChatInputCommandInteraction, ButtonInteraction, MessageFlags } from 'discord.js'; -import Routes from './Routes'; +import { + type ChatInputCommandInteraction, + type ButtonInteraction, + MessageFlags +} from 'discord.js'; +import * as Routes from './Routes'; import { apiFetch } from './ApiClient'; /** * Checks if a player is registered before allowing a gameplay command to proceed. * Returns the API response data if registered, or null if not (after sending an error reply). - * + * * Usage: * const playerData = await PlayerGuard.check(interaction); * if (!playerData) return; // Guard already replied with a helpful message */ -export default class PlayerGuard { - /** - * Verify a player exists via the API. If not, reply with a themed onboarding message. - * Designed for commands that call deferReply() first. - */ - public static async check( - interaction: ChatInputCommandInteraction | ButtonInteraction, - discordId?: string - ): Promise { - const id = discordId ?? interaction.user.id; - try { - const res = await apiFetch(Routes.player(id)); - - if (res.status === 404) { - const content = '๐Ÿ“œ **Adventurer not found!**\nYou need to register before you can play. Use the `/register` command to begin your journey!'; +/** + * Verify a player exists via the API. If not, reply with a themed onboarding message. + * Designed for commands that call deferReply() first. + */ +export async function check( + interaction: ChatInputCommandInteraction | ButtonInteraction, + discordId?: string +): Promise { + const id = discordId ?? interaction.user.id; - if (interaction.deferred || interaction.replied) { - await interaction.editReply({ content }); - } else { - await interaction.reply({ content, flags: MessageFlags.Ephemeral }); - } - return null; - } + try { + const res = await apiFetch(Routes.player(id)); - if (!res.ok) return null; + if (res.status === 404) { + const content = + '๐Ÿ“œ **Adventurer not found!**\nYou need to register before you can play. Use the `/register` command to begin your journey!'; - const body = await res.json(); - return body.data ?? body; - } catch { + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content }); + } else { + await interaction.reply({ content, flags: MessageFlags.Ephemeral }); + } return null; } + + if (!res.ok) return null; + + const body = await res.json(); + return body.data ?? body; + } catch { + return null; } -} \ No newline at end of file +} diff --git a/src/utilities/ProfileImageBuilder.ts b/src/utilities/ProfileImageBuilder.ts index 2a0bb06..f9f8e4d 100644 --- a/src/utilities/ProfileImageBuilder.ts +++ b/src/utilities/ProfileImageBuilder.ts @@ -1,284 +1,352 @@ import { createCanvas, loadImage, GlobalFonts } from '@napi-rs/canvas'; -import { IPlayerJSON } from '../interfaces/IPlayerJSON'; -import { IItemJSON } from '../interfaces/IItemJSON'; -import { User } from 'discord.js'; -import ItemManager from '../managers/ItemManager'; +import { type IPlayerJSON } from '../interfaces/IPlayerJSON'; +import { type IItemJSON } from '../interfaces/IItemJSON'; +import { type User } from 'discord.js'; +import * as ItemManager from '../managers/ItemManager'; import { join } from 'path'; // --- CRITICAL FIX: Load the font directly from the project files --- // process.cwd() ensures it always looks in the root folder, regardless of compiled dist/ paths -GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); - -export default class ProfileImageBuilder { - /** - * Build a profile image. - * @param player - Player data from the API - * @param discordUser - Discord User object OR a plain avatar URL string (for worker threads) - * @param itemCache - Optional item lookup map. If omitted, falls back to ItemManager (main thread only). - */ - public static async build( - player: IPlayerJSON, - discordUser: User | string, - itemCache?: Record - ): Promise { - // Resolve item lookup: use provided cache if available, otherwise fall back to ItemManager - const getItem = (id: number): IItemJSON | undefined => { - if (itemCache) return itemCache[id]; - return ItemManager.get(id); - }; - - // Increased height to 880 to comfortably fit the equipment panel - const canvas = createCanvas(800, 880); - const ctx = canvas.getContext('2d'); - - // --- Svelte Logic Conversions --- - const xpToNext = Math.floor(50 * Math.pow((player.level || 1), 1.3)); - const xpProgress = Math.min((player.experience / xpToNext), 1); - const totalAtk = player.stats?.atk || 0; - const totalDef = player.stats?.def || 0; - const currentHp = Math.floor(player.stats?.hp || 0); - const maxHp = player.maxHp || 100; - - // --- 1. Background (Dark Theme) --- - ctx.fillStyle = '#111111'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const bgGradient = ctx.createLinearGradient(0, 0, 0, 150); - bgGradient.addColorStop(0, '#1a1a1a'); - bgGradient.addColorStop(1, '#111111'); - ctx.fillStyle = bgGradient; - ctx.fillRect(0, 0, canvas.width, 150); - - // --- 2. Avatar --- - const avatarSize = 120; - const avatarX = 40; - const avatarY = 40; - - ctx.save(); - ctx.beginPath(); - ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2, true); - ctx.closePath(); - ctx.clip(); - - // Support both a Discord User object (main thread) and a plain URL string (worker thread) - const avatarUrl = typeof discordUser === 'string' +GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' +); + +/** + * Build a profile image. + * @param player - Player data from the API + * @param discordUser - Discord User object OR a plain avatar URL string (for worker threads) + * @param itemCache - Optional item lookup map. If omitted, falls back to ItemManager (main thread only). + */ +export async function build( + player: IPlayerJSON, + discordUser: User | string, + itemCache?: Record +): Promise { + // Resolve item lookup: use provided cache if available, otherwise fall back to ItemManager + const getItem = (id: number): IItemJSON | undefined => { + if (itemCache) return itemCache[id]; + return ItemManager.get(id); + }; + + // Increased height to 880 to comfortably fit the equipment panel + const canvas = createCanvas(800, 880); + const ctx = canvas.getContext('2d'); + + // --- Svelte Logic Conversions --- + const xpToNext = Math.floor(50 * (player.level || 1) ** 1.3); + const xpProgress = Math.min(player.experience / xpToNext, 1); + const totalAtk = player.stats?.atk || 0; + const totalDef = player.stats?.def || 0; + const currentHp = Math.floor(player.stats?.hp || 0); + const maxHp = player.maxHp || 100; + + // --- 1. Background (Dark Theme) --- + ctx.fillStyle = '#111111'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const bgGradient = ctx.createLinearGradient(0, 0, 0, 150); + bgGradient.addColorStop(0, '#1a1a1a'); + bgGradient.addColorStop(1, '#111111'); + ctx.fillStyle = bgGradient; + ctx.fillRect(0, 0, canvas.width, 150); + + // --- 2. Avatar --- + const avatarSize = 120; + const avatarX = 40; + const avatarY = 40; + + ctx.save(); + ctx.beginPath(); + ctx.arc( + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize / 2, + 0, + Math.PI * 2, + true + ); + ctx.closePath(); + ctx.clip(); + + // Support both a Discord User object (main thread) and a plain URL string (worker thread) + const avatarUrl = + typeof discordUser === 'string' ? discordUser : discordUser.displayAvatarURL({ extension: 'png', size: 256 }); - const avatarImage = await loadImage(avatarUrl); - ctx.drawImage(avatarImage, avatarX, avatarY, avatarSize, avatarSize); - ctx.restore(); - - ctx.beginPath(); - ctx.arc(avatarX + avatarSize / 2, avatarY + avatarSize / 2, avatarSize / 2, 0, Math.PI * 2, true); - ctx.lineWidth = 4; - ctx.strokeStyle = '#10b981'; - ctx.stroke(); - - // --- 3. User Info (Name, Privilege, ID) --- - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 36px sans-serif'; - ctx.fillText(player.username, 180, 75); - - // Fixed: Measure width while font is 36px! - const nameWidth = ctx.measureText(player.username).width; - - ctx.fillStyle = '#10b981'; - ctx.font = 'bold 16px sans-serif'; - ctx.fillText(`[${player.privilege.toUpperCase()}]`, 180 + nameWidth + 15, 72); - - ctx.fillStyle = '#9ca3af'; - ctx.font = '14px monospace'; - ctx.fillText(`ID: ${player.id}`, 180, 100); - - // --- 4. Stats Grid --- - const drawGridBox = (x: number, y: number, label: string, value: string, borderColor: string, valueColor: string) => { - ctx.fillStyle = '#1a1a1a'; - ctx.fillRect(x, y, 180, 70); - ctx.fillStyle = borderColor; - ctx.fillRect(x, y, 4, 70); - - ctx.fillStyle = '#6b7280'; - ctx.font = '12px sans-serif'; - ctx.fillText(label.toUpperCase(), x + 15, y + 25); - - ctx.fillStyle = valueColor; - ctx.font = 'bold 24px monospace'; - ctx.fillText(value, x + 15, y + 55); - }; - - drawGridBox(180, 130, 'Level', player.level.toString(), '#eab308', '#ffffff'); - drawGridBox(375, 130, 'Skill Points', player.skillPoints.toString(), '#3b82f6', '#ffffff'); - drawGridBox(570, 130, 'Coins', player.coins.toLocaleString(), '#f59e0b', '#fbbf24'); - - // --- 5. XP Bar --- - const barX = 180; - const barY = 220; - const barWidth = 570; - const barHeight = 24; - - ctx.fillStyle = '#1f2937'; - ctx.beginPath(); - ctx.roundRect(barX, barY, barWidth, barHeight, 12); - ctx.fill(); - - ctx.fillStyle = '#059669'; - ctx.beginPath(); - ctx.roundRect(barX, barY, barWidth * xpProgress, barHeight, 12); - ctx.fill(); - - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 12px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(`${player.experience.toLocaleString()} XP / ${xpToNext.toLocaleString()} XP`, barX + (barWidth / 2), barY + 16); - ctx.textAlign = 'left'; - - // --- 6. Combat Stats Panel --- - const panelY = 280; + const avatarImage = await loadImage(avatarUrl); + ctx.drawImage(avatarImage, avatarX, avatarY, avatarSize, avatarSize); + ctx.restore(); + + ctx.beginPath(); + ctx.arc( + avatarX + avatarSize / 2, + avatarY + avatarSize / 2, + avatarSize / 2, + 0, + Math.PI * 2, + true + ); + ctx.lineWidth = 4; + ctx.strokeStyle = '#10b981'; + ctx.stroke(); + + // --- 3. User Info (Name, Privilege, ID) --- + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 36px sans-serif'; + ctx.fillText(player.username, 180, 75); + + // Fixed: Measure width while font is 36px! + const nameWidth = ctx.measureText(player.username).width; + + ctx.fillStyle = '#10b981'; + ctx.font = 'bold 16px sans-serif'; + ctx.fillText(`[${player.privilege.toUpperCase()}]`, 180 + nameWidth + 15, 72); + + ctx.fillStyle = '#9ca3af'; + ctx.font = '14px monospace'; + ctx.fillText(`ID: ${player.id}`, 180, 100); + + // --- 4. Stats Grid --- + const drawGridBox = ( + x: number, + y: number, + label: string, + value: string, + borderColor: string, + valueColor: string + ): void => { ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(x, y, 180, 70); + ctx.fillStyle = borderColor; + ctx.fillRect(x, y, 4, 70); + + ctx.fillStyle = '#6b7280'; + ctx.font = '12px sans-serif'; + ctx.fillText(label.toUpperCase(), x + 15, y + 25); + + ctx.fillStyle = valueColor; + ctx.font = 'bold 24px monospace'; + ctx.fillText(value, x + 15, y + 55); + }; + + drawGridBox(180, 130, 'Level', player.level.toString(), '#eab308', '#ffffff'); + drawGridBox( + 375, + 130, + 'Skill Points', + player.skillPoints.toString(), + '#3b82f6', + '#ffffff' + ); + drawGridBox( + 570, + 130, + 'Coins', + player.coins.toLocaleString(), + '#f59e0b', + '#fbbf24' + ); + + // --- 5. XP Bar --- + const barX = 180; + const barY = 220; + const barWidth = 570; + const barHeight = 24; + + ctx.fillStyle = '#1f2937'; + ctx.beginPath(); + ctx.roundRect(barX, barY, barWidth, barHeight, 12); + ctx.fill(); + + ctx.fillStyle = '#059669'; + ctx.beginPath(); + ctx.roundRect(barX, barY, barWidth * xpProgress, barHeight, 12); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText( + `${player.experience.toLocaleString()} XP / ${xpToNext.toLocaleString()} XP`, + barX + barWidth / 2, + barY + 16 + ); + ctx.textAlign = 'left'; + + // --- 6. Combat Stats Panel --- + const panelY = 280; + ctx.fillStyle = '#1a1a1a'; + ctx.beginPath(); + ctx.roundRect(40, panelY, 710, 130, 8); + ctx.fill(); + + ctx.fillStyle = '#f87171'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText('HEALTH POINTS', 60, panelY + 30); + ctx.textAlign = 'right'; + ctx.fillText(`${currentHp} / ${maxHp}`, 730, panelY + 30); + ctx.textAlign = 'left'; + + const hpProgress = Math.min(currentHp / maxHp, 1); + ctx.fillStyle = '#1f2937'; + ctx.beginPath(); + ctx.roundRect(60, panelY + 45, 670, 12, 6); + ctx.fill(); + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.roundRect(60, panelY + 45, 670 * hpProgress, 12, 6); + ctx.fill(); + + const drawStatBox = ( + x: number, + y: number, + label: string, + value: string, + color: string + ): void => { + ctx.fillStyle = '#00000066'; ctx.beginPath(); - ctx.roundRect(40, panelY, 710, 130, 8); + ctx.roundRect(x, y, 325, 50, 6); ctx.fill(); - ctx.fillStyle = '#f87171'; + ctx.fillStyle = '#6b7280'; ctx.font = 'bold 14px sans-serif'; - ctx.fillText('HEALTH POINTS', 60, panelY + 30); - ctx.textAlign = 'right'; - ctx.fillText(`${currentHp} / ${maxHp}`, 730, panelY + 30); - ctx.textAlign = 'left'; - - const hpProgress = Math.min((currentHp / maxHp), 1); - ctx.fillStyle = '#1f2937'; - ctx.beginPath(); - ctx.roundRect(60, panelY + 45, 670, 12, 6); - ctx.fill(); - ctx.fillStyle = '#ef4444'; - ctx.beginPath(); - ctx.roundRect(60, panelY + 45, 670 * hpProgress, 12, 6); - ctx.fill(); - - const drawStatBox = (x: number, y: number, label: string, value: string, color: string) => { - ctx.fillStyle = '#00000066'; - ctx.beginPath(); - ctx.roundRect(x, y, 325, 50, 6); - ctx.fill(); - - ctx.fillStyle = '#6b7280'; - ctx.font = 'bold 14px sans-serif'; - ctx.fillText(label, x + 15, y + 30); - - ctx.fillStyle = color; - ctx.font = 'bold 22px monospace'; - ctx.fillText(value, x + 100, y + 33); - }; - - drawStatBox(60, panelY + 70, 'ATK', totalAtk.toString(), '#f87171'); - drawStatBox(405, panelY + 70, 'DEF', totalDef.toString(), '#60a5fa'); + ctx.fillText(label, x + 15, y + 30); + + ctx.fillStyle = color; + ctx.font = 'bold 22px monospace'; + ctx.fillText(value, x + 100, y + 33); + }; + + drawStatBox(60, panelY + 70, 'ATK', totalAtk.toString(), '#f87171'); + drawStatBox(405, panelY + 70, 'DEF', totalDef.toString(), '#60a5fa'); + + // --- 7. Equipment Grid Panel --- + const equipPanelY = 440; + ctx.fillStyle = '#1a1a1a'; + ctx.beginPath(); + ctx.roundRect(40, equipPanelY, 710, 400, 8); + ctx.fill(); + + // Equipment Header + ctx.fillStyle = '#facc15'; + ctx.beginPath(); + ctx.arc(60, equipPanelY + 30, 4, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#facc15'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText('EQUIPMENT', 75, equipPanelY + 35); + + // Grid Settings (4 columns, 3 rows) + const equipSlots = [ + { key: 'Head', icon: 'โ›‘๏ธ' }, + { key: 'Necklace', icon: '๐Ÿ“ฟ' }, + { key: 'Chest', icon: '๐Ÿ‘•' }, + { key: 'MainHand', icon: 'โš”๏ธ' }, + { key: 'Legs', icon: '๐Ÿ‘–' }, + { key: 'OffHand', icon: '๐Ÿ›ก๏ธ' }, + { key: 'Hands', icon: '๐Ÿงค' }, + { key: 'RingA', icon: '๐Ÿ’' }, + { key: 'Feet', icon: '๐Ÿ‘ข' }, + { key: 'RingB', icon: '๐Ÿ’' }, + { key: 'Pet', icon: '๐Ÿพ' }, + { key: 'Special', icon: 'โœจ' } + ]; + + const RARITY_COLORS: Record = { + Common: '#b0b0b0', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff', + Exotic: '#ff00cc' + }; + + const gridStartX = 60; + const gridStartY = equipPanelY + 65; + const boxWidth = 160; + const boxHeight = 95; + const gapX = 10; + const gapY = 15; + + for (let i = 0; i < equipSlots.length; i++) { + const slot = equipSlots[i]; + const col = i % 4; + const row = Math.floor(i / 4); + + const boxX = gridStartX + col * (boxWidth + gapX); + const boxY = gridStartY + row * (boxHeight + gapY); + + // Fetch item data if equipped + const equippedRef = player.equipment + ? (player.equipment as any)[slot.key] + : null; + let itemData = null; + if (equippedRef?.itemId) { + itemData = getItem(equippedRef.itemId) ?? null; + } - // --- 7. Equipment Grid Panel --- - const equipPanelY = 440; - ctx.fillStyle = '#1a1a1a'; + // Box BG & Outline + ctx.fillStyle = '#00000066'; // bg-black/40 ctx.beginPath(); - ctx.roundRect(40, equipPanelY, 710, 400, 8); + ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 8); ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = '#ffffff11'; + ctx.stroke(); - // Equipment Header - ctx.fillStyle = '#facc15'; - ctx.beginPath(); - ctx.arc(60, equipPanelY + 30, 4, 0, Math.PI * 2); - ctx.fill(); + ctx.textAlign = 'center'; - ctx.fillStyle = '#facc15'; - ctx.font = 'bold 14px sans-serif'; - ctx.fillText('EQUIPMENT', 75, equipPanelY + 35); - - // Grid Settings (4 columns, 3 rows) - const equipSlots = [ - { key: 'Head', icon: 'โ›‘๏ธ' }, { key: 'Necklace', icon: '๐Ÿ“ฟ' }, { key: 'Chest', icon: '๐Ÿ‘•' }, { key: 'MainHand', icon: 'โš”๏ธ' }, - { key: 'Legs', icon: '๐Ÿ‘–' }, { key: 'OffHand', icon: '๐Ÿ›ก๏ธ' }, { key: 'Hands', icon: '๐Ÿงค' }, { key: 'RingA', icon: '๐Ÿ’' }, - { key: 'Feet', icon: '๐Ÿ‘ข' }, { key: 'RingB', icon: '๐Ÿ’' }, { key: 'Pet', icon: '๐Ÿพ' }, { key: 'Special', icon: 'โœจ' } - ]; - - const RARITY_COLORS: Record = { - Common: '#b0b0b0', Uncommon: '#2ecc71', Rare: '#3498db', - Elite: '#e67e22', Epic: '#9b59b6', Legendary: '#f1c40f', - Divine: '#00e5ff', Exotic: '#ff00cc' - }; - - const gridStartX = 60; - const gridStartY = equipPanelY + 65; - const boxWidth = 160; - const boxHeight = 95; - const gapX = 10; - const gapY = 15; - - for (let i = 0; i < equipSlots.length; i++) { - const slot = equipSlots[i]; - const col = i % 4; - const row = Math.floor(i / 4); - - const boxX = gridStartX + col * (boxWidth + gapX); - const boxY = gridStartY + row * (boxHeight + gapY); - - // Fetch item data if equipped - const equippedRef = player.equipment ? (player.equipment as any)[slot.key] : null; - let itemData = null; - if (equippedRef && equippedRef.itemId) { - itemData = getItem(equippedRef.itemId) ?? null; - } - - // Box BG & Outline - ctx.fillStyle = '#00000066'; // bg-black/40 - ctx.beginPath(); - ctx.roundRect(boxX, boxY, boxWidth, boxHeight, 8); - ctx.fill(); - ctx.lineWidth = 1; - ctx.strokeStyle = '#ffffff11'; - ctx.stroke(); - - ctx.textAlign = 'center'; - - // --- UPDATED EMOJI RENDERING --- - ctx.fillStyle = '#ffffff66'; // Muted icon - // Use the NotoEmoji font we registered above - ctx.font = '20px "NotoEmoji", sans-serif'; - ctx.fillText(slot.icon, boxX + boxWidth / 2, boxY + 30); - - // Slot Key - ctx.fillStyle = '#4b5563'; // text-gray-600 - // Instantly reset the font back to standard sans-serif for regular text - ctx.font = 'bold 10px sans-serif'; - ctx.fillText(slot.key.toUpperCase(), boxX + boxWidth / 2, boxY + 45); - - if (itemData) { - const color = RARITY_COLORS[itemData.rarity] || '#ffffff'; - - // Truncate name if it's too long (using canvas maxWidth param) - ctx.fillStyle = color; - ctx.font = 'bold 13px sans-serif'; - ctx.fillText(itemData.name, boxX + boxWidth / 2, boxY + 65, boxWidth - 10); - - // Item Level - ctx.fillStyle = '#6b7280'; - ctx.font = '10px sans-serif'; - ctx.fillText(`Lvl ${itemData.level}`, boxX + boxWidth / 2, boxY + 80); - - // Bottom Border (matches rarity) - ctx.beginPath(); - ctx.moveTo(boxX + 15, boxY + boxHeight); - ctx.lineTo(boxX + boxWidth - 15, boxY + boxHeight); - ctx.lineWidth = 3; - ctx.strokeStyle = color; - ctx.stroke(); - } else { - // Empty State - ctx.fillStyle = '#374151'; // text-gray-700 - ctx.font = 'italic 12px sans-serif'; - ctx.fillText('Empty', boxX + boxWidth / 2, boxY + 70); - } - - ctx.textAlign = 'left'; // Reset alignment for next loop iteration + // --- UPDATED EMOJI RENDERING --- + ctx.fillStyle = '#ffffff66'; // Muted icon + // Use the NotoEmoji font we registered above + ctx.font = '20px "NotoEmoji", sans-serif'; + ctx.fillText(slot.icon, boxX + boxWidth / 2, boxY + 30); + + // Slot Key + ctx.fillStyle = '#4b5563'; // text-gray-600 + // Instantly reset the font back to standard sans-serif for regular text + ctx.font = 'bold 10px sans-serif'; + ctx.fillText(slot.key.toUpperCase(), boxX + boxWidth / 2, boxY + 45); + + if (itemData) { + const color = RARITY_COLORS[itemData.rarity] || '#ffffff'; + + // Truncate name if it's too long (using canvas maxWidth param) + ctx.fillStyle = color; + ctx.font = 'bold 13px sans-serif'; + ctx.fillText( + itemData.name, + boxX + boxWidth / 2, + boxY + 65, + boxWidth - 10 + ); + + // Item Level + ctx.fillStyle = '#6b7280'; + ctx.font = '10px sans-serif'; + ctx.fillText(`Lvl ${itemData.level}`, boxX + boxWidth / 2, boxY + 80); + + // Bottom Border (matches rarity) + ctx.beginPath(); + ctx.moveTo(boxX + 15, boxY + boxHeight); + ctx.lineTo(boxX + boxWidth - 15, boxY + boxHeight); + ctx.lineWidth = 3; + ctx.strokeStyle = color; + ctx.stroke(); + } else { + // Empty State + ctx.fillStyle = '#374151'; // text-gray-700 + ctx.font = 'italic 12px sans-serif'; + ctx.fillText('Empty', boxX + boxWidth / 2, boxY + 70); } - return canvas.toBuffer('image/png'); + ctx.textAlign = 'left'; // Reset alignment for next loop iteration } -} \ No newline at end of file + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/Routes.ts b/src/utilities/Routes.ts index 726cbfa..089a6c1 100644 --- a/src/utilities/Routes.ts +++ b/src/utilities/Routes.ts @@ -1,190 +1,200 @@ -export default class Routes { - public static HEADERS = () => ({ - 'Authorization': `Bearer ${process.env.BOT_TOKEN}`, +export function HEADERS(): Record { + return { + Authorization: `Bearer ${process.env.BOT_TOKEN}`, 'Content-Type': 'application/json' - }); + }; +} - // ========== PLAYER ========== +// ========== PLAYER ========== - public static player(userId: string): string { - return `https://capi.gg/api/bot/player/${userId}`; - } +export function player(userId: string): string { + return `https://capi.gg/api/bot/player/${userId}`; +} - public static registerPlayer(): string { - return 'https://capi.gg/api/bot/player/register'; - } +export function registerPlayer(): string { + return 'https://capi.gg/api/bot/player/register'; +} - // ========== INVENTORY ========== +// ========== INVENTORY ========== - public static inventory(userId: string): string { - return `https://capi.gg/api/bot/player/inventory/${userId}/all`; - } +export function inventory(userId: string): string { + return `https://capi.gg/api/bot/player/inventory/${userId}/all`; +} - public static inventoryItem(userId: string, itemId: number) { - return `https://capi.gg/api/bot/player/inventory/${userId}/${itemId}`; - } +export function inventoryItem(userId: string, itemId: number): string { + return `https://capi.gg/api/bot/player/inventory/${userId}/${itemId}`; +} - // ========== ITEMS ========== +// ========== ITEMS ========== - public static item(itemId: number): string { - return `https://capi.gg/api/bot/items/${itemId}`; - } +export function item(itemId: number): string { + return `https://capi.gg/api/bot/items/${itemId}`; +} - public static items(): string { - return 'https://capi.gg/api/bot/items/all'; - } +export function items(): string { + return 'https://capi.gg/api/bot/items/all'; +} - // ========== SCENARIOS ========== +// ========== SCENARIOS ========== - public static scenario(scenarioId: number): string { - return `https://capi.gg/api/bot/scenarios/${scenarioId}`; - } +export function scenario(scenarioId: number): string { + return `https://capi.gg/api/bot/scenarios/${scenarioId}`; +} - public static scenarios(): string { - return 'https://capi.gg/api/bot/scenarios/all'; - } +export function scenarios(): string { + return 'https://capi.gg/api/bot/scenarios/all'; +} - // ========== NPCS ========== +// ========== NPCS ========== - public static npc(npcId: number): string { - return `https://capi.gg/api/bot/npcs/${npcId}`; - } +export function npc(npcId: number): string { + return `https://capi.gg/api/bot/npcs/${npcId}`; +} - public static npcs(): string { - return `https://capi.gg/api/bot/npcs/all`; - } +export function npcs(): string { + return `https://capi.gg/api/bot/npcs/all`; +} - // ========== INVENTORY ACTIONS ========== +// ========== INVENTORY ACTIONS ========== - public static equip(): string { - return 'https://capi.gg/api/inventory/equip'; - } +export function equip(): string { + return 'https://capi.gg/api/inventory/equip'; +} - public static unequip(): string { - return 'https://capi.gg/api/inventory/unequip'; - } +export function unequip(): string { + return 'https://capi.gg/api/inventory/unequip'; +} - public static lock(): string { - return 'https://capi.gg/api/inventory/lock'; - } +export function lock(): string { + return 'https://capi.gg/api/inventory/lock'; +} - public static consume(): string { - return 'https://capi.gg/api/inventory/consume'; - } +export function consume(): string { + return 'https://capi.gg/api/inventory/consume'; +} - public static sell(): string { - return 'https://capi.gg/api/inventory/sell'; - } +export function sell(): string { + return 'https://capi.gg/api/inventory/sell'; +} - public static enhance(): string { - return 'https://capi.gg/api/inventory/enhance'; - } +export function enhance(): string { + return 'https://capi.gg/api/inventory/enhance'; +} - public static reforge(): string { - return 'https://capi.gg/api/inventory/reforge'; - } +export function reforge(): string { + return 'https://capi.gg/api/inventory/reforge'; +} - public static dismantle(): string { - return 'https://capi.gg/api/inventory/dismantle'; - } +export function dismantle(): string { + return 'https://capi.gg/api/inventory/dismantle'; +} - public static collectionAdd(): string { - return 'https://capi.gg/api/collection/add'; - } +export function collectionAdd(): string { + return 'https://capi.gg/api/collection/add'; +} - // ========== ADVENTURE ========== +// ========== ADVENTURE ========== - public static explore(): string { - return 'https://capi.gg/api/adventure/step'; - } +export function explore(): string { + return 'https://capi.gg/api/adventure/step'; +} - public static combat(): string { - return 'https://capi.gg/api/adventure/combat'; - } +export function combat(): string { + return 'https://capi.gg/api/adventure/combat'; +} - public static rest(): string { - return 'https://capi.gg/api/adventure/rest'; - } +export function rest(): string { + return 'https://capi.gg/api/adventure/rest'; +} - public static travel(): string { - return 'https://capi.gg/api/adventure/travel'; - } +export function travel(): string { + return 'https://capi.gg/api/adventure/travel'; +} - // ========== TASKS ========== +// ========== TASKS ========== - public static tasks(): string { - return 'https://capi.gg/api/tasks'; - } +export function tasks(): string { + return 'https://capi.gg/api/tasks'; +} - // ========== CHESTS ========== +// ========== CHESTS ========== - public static chests(): string { - return 'https://capi.gg/api/chests'; - } +export function chests(): string { + return 'https://capi.gg/api/chests'; +} - // ========== LEADERBOARD ========== +// ========== LEADERBOARD ========== - public static leaderboard(stat: string): string { - return `https://capi.gg/api/bot/leaderboard?stat=${stat}`; - } +export function leaderboard(stat: string): string { + return `https://capi.gg/api/bot/leaderboard?stat=${stat}`; +} - // ========== TELEMETRY ========== +// ========== TELEMETRY ========== - public static telemetry(): string { - return 'https://capi.gg/api/telemetry/db-stats'; - } +export function telemetry(): string { + return 'https://capi.gg/api/telemetry/db-stats'; +} - // ========== MARKET ========== - - public static marketBrowse(discordId: string, params?: { page?: number, search?: string, rarity?: string, type?: string, sort?: string }): string { - const base = `https://capi.gg/api/market?discordId=${discordId}&limit=8`; - const qs = new URLSearchParams(); - if (params?.page) qs.set('page', String(params.page)); - if (params?.search) qs.set('search', params.search); - if (params?.rarity && params.rarity !== 'All') qs.set('rarity', params.rarity); - if (params?.type && params.type !== 'All') qs.set('type', params.type); - if (params?.sort) qs.set('sort', params.sort); - const extra = qs.toString(); - return extra ? `${base}&${extra}` : base; - } +// ========== MARKET ========== - public static marketMyListings(discordId: string, page: number = 1): string { - return `https://capi.gg/api/market?sellerId=${discordId}&discordId=${discordId}&limit=8&page=${page}`; +export function marketBrowse( + discordId: string, + params?: { + page?: number; + search?: string; + rarity?: string; + type?: string; + sort?: string; } +): string { + const base = `https://capi.gg/api/market?discordId=${discordId}&limit=8`; + const qs = new URLSearchParams(); + if (params?.page) qs.set('page', String(params.page)); + if (params?.search) qs.set('search', params.search); + if (params?.rarity && params.rarity !== 'All') + qs.set('rarity', params.rarity); + if (params?.type && params.type !== 'All') qs.set('type', params.type); + if (params?.sort) qs.set('sort', params.sort); + const extra = qs.toString(); + return extra ? `${base}&${extra}` : base; +} - public static marketBuy(): string { - return 'https://capi.gg/api/market/buy'; - } +export function marketMyListings(discordId: string, page: number = 1): string { + return `https://capi.gg/api/market?sellerId=${discordId}&discordId=${discordId}&limit=8&page=${page}`; +} - public static marketList(): string { - return 'https://capi.gg/api/market/list'; - } +export function marketBuy(): string { + return 'https://capi.gg/api/market/buy'; +} - public static marketCancel(): string { - return 'https://capi.gg/api/market/cancel'; - } +export function marketList(): string { + return 'https://capi.gg/api/market/list'; +} - public static marketTrend(itemId: number): string { - return `https://capi.gg/api/market/trend?itemId=${itemId}`; - } +export function marketCancel(): string { + return 'https://capi.gg/api/market/cancel'; +} - // ========== PROFILE ========== +export function marketTrend(itemId: number): string { + return `https://capi.gg/api/market/trend?itemId=${itemId}`; +} - public static allocate(): string { - return 'https://capi.gg/api/profile/allocate'; - } +// ========== PROFILE ========== - // ========== BULK OPERATIONS ========== +export function allocate(): string { + return 'https://capi.gg/api/profile/allocate'; +} - public static bulkSell(): string { - return 'https://capi.gg/api/inventory/bulk-sell'; - } +// ========== BULK OPERATIONS ========== - public static bulkCollect(): string { - return 'https://capi.gg/api/collection/bulk-add'; - } +export function bulkSell(): string { + return 'https://capi.gg/api/inventory/bulk-sell'; +} - public static bulkDismantle(): string { - return 'https://capi.gg/api/inventory/bulk-dismantle'; - } -} \ No newline at end of file +export function bulkCollect(): string { + return 'https://capi.gg/api/collection/bulk-add'; +} + +export function bulkDismantle(): string { + return 'https://capi.gg/api/inventory/bulk-dismantle'; +} diff --git a/src/utilities/TasksImageBuilder.ts b/src/utilities/TasksImageBuilder.ts index c95347d..0329c3d 100644 --- a/src/utilities/TasksImageBuilder.ts +++ b/src/utilities/TasksImageBuilder.ts @@ -2,12 +2,20 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; import { join } from 'path'; import type { ITaskJSON } from '../interfaces/IGameJSON'; -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} - -const PERIOD_COLORS: Record = { - daily: { bg: '#064e3b33', border: '#10b98144', text: '#34d399' }, - weekly: { bg: '#1e1b4b33', border: '#6366f144', text: '#818cf8' }, - monthly: { bg: '#4a044e33', border: '#c026d344', text: '#e879f9' }, +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} + +const PERIOD_COLORS: Record< + string, + { bg: string; border: string; text: string } +> = { + daily: { bg: '#064e3b33', border: '#10b98144', text: '#34d399' }, + weekly: { bg: '#1e1b4b33', border: '#6366f144', text: '#818cf8' }, + monthly: { bg: '#4a044e33', border: '#c026d344', text: '#e879f9' } }; export interface TasksPageConfig { @@ -16,164 +24,185 @@ export interface TasksPageConfig { playerEmbers: number; } -export default class TasksImageBuilder { - public static async build(tasks: ITaskJSON[], config: TasksPageConfig): Promise { - const rowH = 90; - const headerH = 100; - const footerH = 40; - const canvasH = headerH + (tasks.length * rowH) + footerH + 20; - const canvas = createCanvas(800, Math.max(300, canvasH)); - const ctx = canvas.getContext('2d'); - - // Background - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Header gradient - const grad = ctx.createLinearGradient(0, 0, 0, 100); - grad.addColorStop(0, '#1a1a1a'); - grad.addColorStop(1, '#0a0a0a'); - ctx.fillStyle = grad; - ctx.fillRect(0, 0, canvas.width, 100); - - // Title - ctx.fillStyle = '#ffffff'; - ctx.font = 'bold 28px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText('๐Ÿ“‹', 30, 45); - ctx.font = 'bold 28px sans-serif'; - ctx.fillText('TASK BOARD', 70, 45); - - // Period badge - const pc = PERIOD_COLORS[config.period] || PERIOD_COLORS.daily; - ctx.font = 'bold 12px sans-serif'; - ctx.textAlign = 'right'; - const periodText = config.period.toUpperCase(); - const pw = ctx.measureText(periodText).width + 20; - ctx.fillStyle = pc.bg; - ctx.strokeStyle = pc.border; +export async function build( + tasks: ITaskJSON[], + config: TasksPageConfig +): Promise { + const rowH = 90; + const headerH = 100; + const footerH = 40; + const canvasH = headerH + tasks.length * rowH + footerH + 20; + const canvas = createCanvas(800, Math.max(300, canvasH)); + const ctx = canvas.getContext('2d'); + + // Background + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Header gradient + const grad = ctx.createLinearGradient(0, 0, 0, 100); + grad.addColorStop(0, '#1a1a1a'); + grad.addColorStop(1, '#0a0a0a'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, canvas.width, 100); + + // Title + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 28px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('๐Ÿ“‹', 30, 45); + ctx.font = 'bold 28px sans-serif'; + ctx.fillText('TASK BOARD', 70, 45); + + // Period badge + const pc = PERIOD_COLORS[config.period] || PERIOD_COLORS.daily; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'right'; + const periodText = config.period.toUpperCase(); + const pw = ctx.measureText(periodText).width + 20; + ctx.fillStyle = pc.bg; + ctx.strokeStyle = pc.border; + ctx.beginPath(); + ctx.roundRect(canvas.width - 30 - pw, 25, pw, 28, 6); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = pc.text; + ctx.textAlign = 'center'; + ctx.fillText(periodText, canvas.width - 30 - pw / 2, 44); + + // Reset timer + const resetMin = Math.floor(config.resetIn / 60000); + const resetH = Math.floor(resetMin / 60); + const resetStr = resetH > 0 ? `${resetH}h ${resetMin % 60}m` : `${resetMin}m`; + ctx.fillStyle = '#6b7280'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(`RESETS IN: ${resetStr}`, canvas.width - 30, 70); + + // Embers display + ctx.fillStyle = '#f97316'; + ctx.font = 'bold 10px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`๐Ÿ”ฅ ${config.playerEmbers.toLocaleString()} EMBERS`, 30, 70); + + // Divider + ctx.beginPath(); + ctx.moveTo(30, 85); + ctx.lineTo(canvas.width - 30, 85); + ctx.strokeStyle = '#ffffff1a'; + ctx.lineWidth = 1; + ctx.stroke(); + + // Task rows + let y = headerH; + + for (const task of tasks) { + const pct = Math.min(100, Math.floor(task.progress / task.target * 100)); + const isComplete = task.progress >= task.target; + const isClaimed = task.claimed; + + // Row background + ctx.fillStyle = isClaimed + ? '#00000020' + : isComplete + ? '#064e3b22' + : '#ffffff06'; ctx.beginPath(); - ctx.roundRect(canvas.width - 30 - pw, 25, pw, 28, 6); + ctx.roundRect(30, y, canvas.width - 60, rowH - 10, 10); ctx.fill(); + ctx.strokeStyle = isClaimed + ? '#ffffff0a' + : isComplete + ? '#10b98133' + : '#ffffff10'; + ctx.lineWidth = 1; ctx.stroke(); - ctx.fillStyle = pc.text; - ctx.textAlign = 'center'; - ctx.fillText(periodText, canvas.width - 30 - pw / 2, 44); - // Reset timer - const resetMin = Math.floor(config.resetIn / 60000); - const resetH = Math.floor(resetMin / 60); - const resetStr = resetH > 0 ? `${resetH}h ${resetMin % 60}m` : `${resetMin}m`; - ctx.fillStyle = '#6b7280'; - ctx.font = '10px sans-serif'; - ctx.textAlign = 'right'; - ctx.fillText(`RESETS IN: ${resetStr}`, canvas.width - 30, 70); - - // Embers display - ctx.fillStyle = '#f97316'; - ctx.font = 'bold 10px sans-serif'; + // Icon + ctx.font = '24px "NotoEmoji", sans-serif'; ctx.textAlign = 'left'; - ctx.fillText(`๐Ÿ”ฅ ${config.playerEmbers.toLocaleString()} EMBERS`, 30, 70); - - // Divider + ctx.globalAlpha = isClaimed ? 0.3 : 1; + ctx.fillText(task.icon || '๐Ÿ“‹', 50, y + 35); + + // Description + ctx.font = isClaimed ? 'italic 14px sans-serif' : 'bold 14px sans-serif'; + ctx.fillStyle = isClaimed ? '#6b7280' : '#ffffff'; + ctx.fillText(task.label, 90, y + 30, 400); + + // Progress text + ctx.font = '11px monospace'; + ctx.fillStyle = isComplete ? '#34d399' : '#9ca3af'; + ctx.fillText( + `${task.progress.toLocaleString()} / ${task.target.toLocaleString()}`, + 90, + y + 50 + ); + + // Progress bar + const barX = 90; + const barY = y + 58; + const barW = 350; + const barH = 8; + + ctx.fillStyle = '#ffffff10'; ctx.beginPath(); - ctx.moveTo(30, 85); - ctx.lineTo(canvas.width - 30, 85); - ctx.strokeStyle = '#ffffff1a'; - ctx.lineWidth = 1; - ctx.stroke(); - - // Task rows - let y = headerH; - - for (const task of tasks) { - const pct = Math.min(100, Math.floor((task.progress / task.target) * 100)); - const isComplete = task.progress >= task.target; - const isClaimed = task.claimed; + ctx.roundRect(barX, barY, barW, barH, 4); + ctx.fill(); - // Row background - ctx.fillStyle = isClaimed ? '#00000020' : isComplete ? '#064e3b22' : '#ffffff06'; - ctx.beginPath(); - ctx.roundRect(30, y, canvas.width - 60, rowH - 10, 10); - ctx.fill(); - ctx.strokeStyle = isClaimed ? '#ffffff0a' : isComplete ? '#10b98133' : '#ffffff10'; - ctx.lineWidth = 1; - ctx.stroke(); - - // Icon - ctx.font = '24px "NotoEmoji", sans-serif'; - ctx.textAlign = 'left'; - ctx.globalAlpha = isClaimed ? 0.3 : 1; - ctx.fillText(task.icon || '๐Ÿ“‹', 50, y + 35); - - // Description - ctx.font = isClaimed ? 'italic 14px sans-serif' : 'bold 14px sans-serif'; - ctx.fillStyle = isClaimed ? '#6b7280' : '#ffffff'; - ctx.fillText(task.label, 90, y + 30, 400); - - // Progress text - ctx.font = '11px monospace'; - ctx.fillStyle = isComplete ? '#34d399' : '#9ca3af'; - ctx.fillText(`${task.progress.toLocaleString()} / ${task.target.toLocaleString()}`, 90, y + 50); - - // Progress bar - const barX = 90; - const barY = y + 58; - const barW = 350; - const barH = 8; - - ctx.fillStyle = '#ffffff10'; + if (pct > 0) { + ctx.fillStyle = isClaimed + ? '#4b5563' + : isComplete + ? '#10b981' + : '#3b82f6'; ctx.beginPath(); - ctx.roundRect(barX, barY, barW, barH, 4); + ctx.roundRect(barX, barY, barW * (pct / 100), barH, 4); ctx.fill(); + } - if (pct > 0) { - ctx.fillStyle = isClaimed ? '#4b5563' : isComplete ? '#10b981' : '#3b82f6'; - ctx.beginPath(); - ctx.roundRect(barX, barY, barW * (pct / 100), barH, 4); - ctx.fill(); - } + // Percentage + ctx.font = 'bold 10px sans-serif'; + ctx.fillStyle = isComplete ? '#34d399' : '#6b7280'; + ctx.textAlign = 'left'; + ctx.fillText(`${pct}%`, barX + barW + 10, barY + 8); - // Percentage - ctx.font = 'bold 10px sans-serif'; - ctx.fillStyle = isComplete ? '#34d399' : '#6b7280'; - ctx.textAlign = 'left'; - ctx.fillText(`${pct}%`, barX + barW + 10, barY + 8); - - // Rewards (right side) - ctx.textAlign = 'right'; - const rewardX = canvas.width - 50; - - if (isClaimed) { - ctx.fillStyle = '#4b5563'; - ctx.font = 'bold 12px sans-serif'; - ctx.fillText('CLAIMED โœ“', rewardX, y + 40); - } else { - ctx.fillStyle = '#eab308'; - ctx.font = 'bold 11px sans-serif'; - ctx.fillText(`${task.reward.gold.toLocaleString()}g`, rewardX, y + 25); - - ctx.fillStyle = '#60a5fa'; - ctx.font = '10px sans-serif'; - ctx.fillText(`${task.reward.xp.toLocaleString()} XP`, rewardX, y + 42); - - if (task.reward.embers > 0) { - ctx.fillStyle = '#f97316'; - ctx.fillText(`${task.reward.embers} ๐Ÿ”ฅ`, rewardX, y + 57); - } + // Rewards (right side) + ctx.textAlign = 'right'; + const rewardX = canvas.width - 50; + + if (isClaimed) { + ctx.fillStyle = '#4b5563'; + ctx.font = 'bold 12px sans-serif'; + ctx.fillText('CLAIMED โœ“', rewardX, y + 40); + } else { + ctx.fillStyle = '#eab308'; + ctx.font = 'bold 11px sans-serif'; + ctx.fillText(`${task.reward.gold.toLocaleString()}g`, rewardX, y + 25); + + ctx.fillStyle = '#60a5fa'; + ctx.font = '10px sans-serif'; + ctx.fillText(`${task.reward.xp.toLocaleString()} XP`, rewardX, y + 42); + + if (task.reward.embers > 0) { + ctx.fillStyle = '#f97316'; + ctx.fillText(`${task.reward.embers} ๐Ÿ”ฅ`, rewardX, y + 57); } - - ctx.globalAlpha = 1; - y += rowH; } - if (tasks.length === 0) { - ctx.fillStyle = '#6b7280'; - ctx.font = 'italic 16px sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('No tasks available for this period.', canvas.width / 2, headerH + 50); - } + ctx.globalAlpha = 1; + y += rowH; + } - return canvas.toBuffer('image/png'); + if (tasks.length === 0) { + ctx.fillStyle = '#6b7280'; + ctx.font = 'italic 16px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText( + 'No tasks available for this period.', + canvas.width / 2, + headerH + 50 + ); } -} \ No newline at end of file + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/TravelImageBuilder.ts b/src/utilities/TravelImageBuilder.ts index d26ab2d..37619a4 100644 --- a/src/utilities/TravelImageBuilder.ts +++ b/src/utilities/TravelImageBuilder.ts @@ -2,11 +2,20 @@ import { createCanvas, GlobalFonts } from '@napi-rs/canvas'; import { join } from 'path'; import { ZONES, type ZoneInfo } from './ZoneData'; -try { GlobalFonts.registerFromPath(join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), 'NotoEmoji'); } catch(e) {} +try { + GlobalFonts.registerFromPath( + join(process.cwd(), 'assets', 'NotoColorEmoji-Regular.ttf'), + 'NotoEmoji' + ); +} catch (e) {} const RARITY_COLORS: Record = { - Uncommon: '#2ecc71', Rare: '#3498db', Elite: '#e67e22', - Epic: '#9b59b6', Legendary: '#f1c40f', Divine: '#00e5ff', + Uncommon: '#2ecc71', + Rare: '#3498db', + Elite: '#e67e22', + Epic: '#9b59b6', + Legendary: '#f1c40f', + Divine: '#00e5ff' }; const TIER_COLORS: Record = { @@ -15,7 +24,7 @@ const TIER_COLORS: Record = { 'The Hero': '#e67e22', 'The Ascendant': '#9b59b6', 'The Cosmic': '#00e5ff', - 'Beyond': '#ff00cc', + Beyond: '#ff00cc' }; const ROW_HEIGHT = 52; @@ -25,154 +34,170 @@ const FOOTER_HEIGHT = 40; const PADDING = 30; const CANVAS_WIDTH = 800; -export default class TravelImageBuilder { - public static async build(playerLevel: number, currentZoneId: number): Promise { - // Group zones by tier - const tiers = new Map(); - for (const zone of ZONES) { - const tier = zone.tier; - if (!tiers.has(tier)) tiers.set(tier, []); - tiers.get(tier)!.push(zone); - } - - // Calculate canvas height - let totalRows = 0; - let tierCount = 0; - for (const zones of tiers.values()) { - tierCount++; - totalRows += zones.length; - } - - const canvasHeight = HEADER_HEIGHT + (tierCount * TIER_HEADER_HEIGHT) + (totalRows * ROW_HEIGHT) + FOOTER_HEIGHT + PADDING; - - const canvas = createCanvas(CANVAS_WIDTH, canvasHeight); - const ctx = canvas.getContext('2d'); - const contentWidth = CANVAS_WIDTH - (PADDING * 2); - - // --- Background --- - ctx.fillStyle = '#0a0a0a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); +export async function build( + playerLevel: number, + currentZoneId: number +): Promise { + // Group zones by tier + const tiers = new Map(); + for (const zone of ZONES) { + const tier = zone.tier; + if (!tiers.has(tier)) tiers.set(tier, []); + tiers.get(tier)!.push(zone); + } - ctx.strokeStyle = '#ffffff05'; - ctx.lineWidth = 1; - for (let i = 0; i < canvas.height; i += 20) { - ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(canvas.width, i); ctx.stroke(); - } + // Calculate canvas height + let totalRows = 0; + let tierCount = 0; + for (const zones of tiers.values()) { + tierCount++; + totalRows += zones.length; + } - // Header gradient - const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_HEIGHT); - headerGrad.addColorStop(0, '#10b98125'); - headerGrad.addColorStop(1, '#0a0a0a00'); - ctx.fillStyle = headerGrad; - ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); - - // --- Header --- - ctx.textAlign = 'center'; - ctx.fillStyle = '#10b981'; - ctx.font = 'bold 26px sans-serif'; - ctx.fillText('Zone Map', canvas.width / 2, 38); - - const currentZone = ZONES.find(z => z.id === currentZoneId); - ctx.fillStyle = '#6b7280'; - ctx.font = '13px sans-serif'; - ctx.fillText( - `Current: ${currentZone?.name ?? 'Unknown'} โ€ข Level ${playerLevel}`, - canvas.width / 2, 62 - ); - - // --- Zone rows grouped by tier --- - let y = HEADER_HEIGHT; - - for (const [tierName, zones] of tiers) { - const tierColor = TIER_COLORS[tierName] || '#ffffff'; - - // Tier header - ctx.fillStyle = tierColor + '15'; - ctx.fillRect(PADDING, y, contentWidth, TIER_HEADER_HEIGHT - 2); - - ctx.fillStyle = tierColor; - ctx.font = 'bold 13px sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(tierName.toUpperCase(), PADDING + 12, y + 22); - - y += TIER_HEADER_HEIGHT; - - // Zone rows - for (const zone of zones) { - const isCurrentZone = zone.id === currentZoneId; - const isAccessible = playerLevel >= zone.levelReq; - const rarityColor = RARITY_COLORS[zone.rarityCap] || '#6b7280'; - - // Row bg - if (isCurrentZone) { - ctx.fillStyle = '#10b98118'; - } else { - ctx.fillStyle = zone.id % 2 === 0 ? '#ffffff04' : '#00000000'; - } - ctx.beginPath(); - ctx.roundRect(PADDING, y, contentWidth, ROW_HEIGHT - 2, 6); - ctx.fill(); + const canvasHeight = + HEADER_HEIGHT + + tierCount * TIER_HEADER_HEIGHT + + totalRows * ROW_HEIGHT + + FOOTER_HEIGHT + + PADDING; + + const canvas = createCanvas(CANVAS_WIDTH, canvasHeight); + const ctx = canvas.getContext('2d'); + const contentWidth = CANVAS_WIDTH - PADDING * 2; + + // --- Background --- + ctx.fillStyle = '#0a0a0a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = '#ffffff05'; + ctx.lineWidth = 1; + for (let i = 0; i < canvas.height; i += 20) { + ctx.beginPath(); + ctx.moveTo(0, i); + ctx.lineTo(canvas.width, i); + ctx.stroke(); + } - // Current zone indicator - if (isCurrentZone) { - ctx.fillStyle = '#10b981'; - ctx.beginPath(); - ctx.roundRect(PADDING, y, 4, ROW_HEIGHT - 2, [4, 0, 0, 4]); - ctx.fill(); - - ctx.font = '14px "NotoEmoji", sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('๐Ÿ“', PADDING + 22, y + 33); - } - - const textX = isCurrentZone ? PADDING + 42 : PADDING + 16; - - // Zone name - ctx.textAlign = 'left'; - ctx.fillStyle = isAccessible ? '#ffffff' : '#4b5563'; - ctx.font = `${isCurrentZone ? 'bold ' : ''}15px sans-serif`; - ctx.fillText( - `${isAccessible ? '' : '๐Ÿ”’ '}${zone.name}`, - textX, y + 22, 340 - ); - - // Description - ctx.fillStyle = isAccessible ? '#6b7280' : '#374151'; - ctx.font = '11px sans-serif'; - ctx.fillText(zone.description, textX, y + 38, 340); - - // Level req (right side) - ctx.textAlign = 'right'; - ctx.fillStyle = isAccessible ? '#4b5563' : '#ef4444'; - ctx.font = '11px sans-serif'; - ctx.fillText(`Lvl ${zone.levelReq}+`, PADDING + contentWidth - 100, y + 22); - - // Rarity cap pill - ctx.fillStyle = rarityColor + '20'; - ctx.font = '10px sans-serif'; - const pillText = zone.rarityCap; - const pillWidth = ctx.measureText(pillText).width + 14; - const pillX = PADDING + contentWidth - pillWidth - 8; + // Header gradient + const headerGrad = ctx.createLinearGradient(0, 0, 0, HEADER_HEIGHT); + headerGrad.addColorStop(0, '#10b98125'); + headerGrad.addColorStop(1, '#0a0a0a00'); + ctx.fillStyle = headerGrad; + ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); + + // --- Header --- + ctx.textAlign = 'center'; + ctx.fillStyle = '#10b981'; + ctx.font = 'bold 26px sans-serif'; + ctx.fillText('Zone Map', canvas.width / 2, 38); + + const currentZone = ZONES.find((z) => z.id === currentZoneId); + ctx.fillStyle = '#6b7280'; + ctx.font = '13px sans-serif'; + ctx.fillText( + `Current: ${currentZone?.name ?? 'Unknown'} โ€ข Level ${playerLevel}`, + canvas.width / 2, + 62 + ); + + // --- Zone rows grouped by tier --- + let y = HEADER_HEIGHT; + + for (const [tierName, zones] of tiers) { + const tierColor = TIER_COLORS[tierName] || '#ffffff'; + + // Tier header + ctx.fillStyle = `${tierColor}15`; + ctx.fillRect(PADDING, y, contentWidth, TIER_HEADER_HEIGHT - 2); + + ctx.fillStyle = tierColor; + ctx.font = 'bold 13px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(tierName.toUpperCase(), PADDING + 12, y + 22); + + y += TIER_HEADER_HEIGHT; + + // Zone rows + for (const zone of zones) { + const isCurrentZone = zone.id === currentZoneId; + const isAccessible = playerLevel >= zone.levelReq; + const rarityColor = RARITY_COLORS[zone.rarityCap] || '#6b7280'; + + // Row bg + if (isCurrentZone) { + ctx.fillStyle = '#10b98118'; + } else { + ctx.fillStyle = zone.id % 2 === 0 ? '#ffffff04' : '#00000000'; + } + ctx.beginPath(); + ctx.roundRect(PADDING, y, contentWidth, ROW_HEIGHT - 2, 6); + ctx.fill(); + // Current zone indicator + if (isCurrentZone) { + ctx.fillStyle = '#10b981'; ctx.beginPath(); - ctx.roundRect(pillX, y + 28, pillWidth, 16, 3); + ctx.roundRect(PADDING, y, 4, ROW_HEIGHT - 2, [4, 0, 0, 4]); ctx.fill(); - ctx.fillStyle = isAccessible ? rarityColor : '#4b5563'; + ctx.font = '14px "NotoEmoji", sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(pillText, pillX + pillWidth / 2, y + 40); - - y += ROW_HEIGHT; + ctx.fillText('๐Ÿ“', PADDING + 22, y + 33); } - } - // --- Footer --- - y += 10; - ctx.textAlign = 'center'; - ctx.fillStyle = '#374151'; - ctx.font = '11px sans-serif'; - ctx.fillText('โš”๏ธ DFO Zone Map โ€” capi.gg', canvas.width / 2, y); + const textX = isCurrentZone ? PADDING + 42 : PADDING + 16; - return canvas.toBuffer('image/png'); + // Zone name + ctx.textAlign = 'left'; + ctx.fillStyle = isAccessible ? '#ffffff' : '#4b5563'; + ctx.font = `${isCurrentZone ? 'bold ' : ''}15px sans-serif`; + ctx.fillText( + `${isAccessible ? '' : '๐Ÿ”’ '}${zone.name}`, + textX, + y + 22, + 340 + ); + + // Description + ctx.fillStyle = isAccessible ? '#6b7280' : '#374151'; + ctx.font = '11px sans-serif'; + ctx.fillText(zone.description, textX, y + 38, 340); + + // Level req (right side) + ctx.textAlign = 'right'; + ctx.fillStyle = isAccessible ? '#4b5563' : '#ef4444'; + ctx.font = '11px sans-serif'; + ctx.fillText( + `Lvl ${zone.levelReq}+`, + PADDING + contentWidth - 100, + y + 22 + ); + + // Rarity cap pill + ctx.fillStyle = `${rarityColor}20`; + ctx.font = '10px sans-serif'; + const pillText = zone.rarityCap; + const pillWidth = ctx.measureText(pillText).width + 14; + const pillX = PADDING + contentWidth - pillWidth - 8; + + ctx.beginPath(); + ctx.roundRect(pillX, y + 28, pillWidth, 16, 3); + ctx.fill(); + + ctx.fillStyle = isAccessible ? rarityColor : '#4b5563'; + ctx.textAlign = 'center'; + ctx.fillText(pillText, pillX + pillWidth / 2, y + 40); + + y += ROW_HEIGHT; + } } -} \ No newline at end of file + + // --- Footer --- + y += 10; + ctx.textAlign = 'center'; + ctx.fillStyle = '#374151'; + ctx.font = '11px sans-serif'; + ctx.fillText('โš”๏ธ DFO Zone Map โ€” capi.gg', canvas.width / 2, y); + + return canvas.toBuffer('image/png'); +} diff --git a/src/utilities/WorkerPool.ts b/src/utilities/WorkerPool.ts index f95c2c7..c103c13 100644 --- a/src/utilities/WorkerPool.ts +++ b/src/utilities/WorkerPool.ts @@ -10,140 +10,155 @@ interface QueuedTask { reject: (reason: any) => void; } -export default class WorkerPool { - private static workers: Worker[] = []; - private static available: Worker[] = []; - private static queue: QueuedTask[] = []; - private static isInitialized = false; - - /** - * Spin up the pool. Call once at startup (e.g. in ClientReadyEvent). - * Defaults to (CPU cores - 1), minimum 2, capped at 8. - */ - public static init(size?: number): void { - if (this.isInitialized) return; - - const coreCount = cpus().length; - const poolSize = size ?? Math.max(2, Math.min(coreCount - 1, 8)); - - // Resolve to the compiled .js worker in production, or .ts in development - const isCompiled = __filename.endsWith('.js'); - const workerFile = join(__dirname, isCompiled ? 'ImageWorker.js' : 'ImageWorker.ts'); - - for (let i = 0; i < poolSize; i++) { - const worker = new Worker(workerFile, { - // If running raw TypeScript, we need ts-node to compile the worker file - execArgv: isCompiled ? [] : ['-r', 'ts-node/register'], - }); - - worker.on('error', (err) => { - logger.error(err, `[WorkerPool] Worker ${i} encountered an error`); - }); - - worker.on('exit', (code) => { - if (code !== 0) { - logger.error(`[WorkerPool] Worker ${i} exited with code ${code}`); - } - }); - - this.workers.push(worker); - this.available.push(worker); - } - - this.isInitialized = true; - logger.info(`[WorkerPool] Initialized ${poolSize} workers (${coreCount} CPU cores detected)`); - } +export interface Stats { + total: number; + available: number; + queued: number; +} - /** - * Submit a rendering task to the pool. - * If a worker is free it runs immediately; otherwise it queues. - */ - public static run(builderName: string, payload: any): Promise { - if (!this.isInitialized) { - throw new Error('[WorkerPool] Pool not initialized โ€” call WorkerPool.init() first'); - } +let workers: Worker[] = []; +let available: Worker[] = []; +let queue: QueuedTask[] = []; +let isInitialized = false; + +/** + * Spin up the pool. Call once at startup (e.g. in ClientReadyEvent). + * Defaults to (CPU cores - 1), minimum 2, capped at 8. + */ +export function init(size?: number): void { + if (isInitialized) return; + + const coreCount = cpus().length; + const poolSize = size ?? Math.max(2, Math.min(coreCount - 1, 8)); + + // Resolve to the compiled .js worker in production, or .ts in development + const isCompiled = __filename.endsWith('.js'); + const workerFile = join( + __dirname, + isCompiled ? 'ImageWorker.js' : 'ImageWorker.ts' + ); + + for (let i = 0; i < poolSize; i++) { + const worker = new Worker(workerFile, { + // If running raw TypeScript, we need ts-node to compile the worker file + execArgv: isCompiled ? [] : ['-r', 'ts-node/register'] + }); - return new Promise((resolve, reject) => { - const task: QueuedTask = { builderName, payload, resolve, reject }; - const worker = this.available.pop(); + worker.on('error', (err) => { + logger.error(err, `[WorkerPool] Worker ${i} encountered an error`); + }); - if (worker) { - this.execute(worker, task); - } else { - this.queue.push(task); + worker.on('exit', (code) => { + if (code !== 0) { + logger.error(`[WorkerPool] Worker ${i} exited with code ${code}`); } }); - } - /** - * Send a task to a specific worker and listen for the result. - */ - private static execute(worker: Worker, task: QueuedTask): void { - const onMessage = (result: { success: boolean; buffer?: ArrayBuffer; error?: string }) => { - // Clean up this specific listener - worker.off('message', onMessage); - worker.off('error', onError); - - // Return worker to the available pool - this.release(worker); - - if (result.success && result.buffer) { - task.resolve(Buffer.from(result.buffer)); - } else { - task.reject(new Error(result.error ?? 'Unknown worker error')); - } - }; + workers.push(worker); + available.push(worker); + } - const onError = (err: Error) => { - worker.off('message', onMessage); - worker.off('error', onError); + isInitialized = true; + logger.info( + `[WorkerPool] Initialized ${poolSize} workers (${coreCount} CPU cores detected)` + ); +} - this.release(worker); - task.reject(err); - }; +/** + * Submit a rendering task to the pool. + * If a worker is free it runs immediately; otherwise it queues. + */ +export async function run(builderName: string, payload: any): Promise { + if (!isInitialized) { + throw new Error( + '[WorkerPool] Pool not initialized โ€” call WorkerPool.init() first' + ); + } - worker.on('message', onMessage); - worker.on('error', onError); + return new Promise((resolve, reject) => { + const task: QueuedTask = { builderName, payload, resolve, reject }; + const worker = available.pop(); - worker.postMessage({ - builderName: task.builderName, - payload: task.payload, - }); - } + if (worker) { + execute(worker, task); + } else { + queue.push(task); + } + }); +} - /** - * Return a worker to the pool and drain the queue if tasks are waiting. - */ - private static release(worker: Worker): void { - const next = this.queue.shift(); - if (next) { - this.execute(worker, next); +/** + * Send a task to a specific worker and listen for the result. + */ +function execute(worker: Worker, task: QueuedTask): void { + const onMessage = (result: { + success: boolean; + buffer?: ArrayBuffer; + error?: string; + }): void => { + // Clean up this specific listener + worker.off('message', onMessage); + worker.off('error', onError); + + // Return worker to the available pool + release(worker); + + if (result.success && result.buffer) { + task.resolve(Buffer.from(result.buffer)); } else { - this.available.push(worker); + task.reject(new Error(result.error ?? 'Unknown worker error')); } - } + }; - /** - * Gracefully terminate all workers. Call during shutdown. - */ - public static async shutdown(): Promise { - const terminations = this.workers.map((w) => w.terminate()); - await Promise.all(terminations); - this.workers = []; - this.available = []; - this.queue = []; - this.isInitialized = false; - logger.info('[WorkerPool] All workers terminated'); - } + const onError = (err: Error): void => { + worker.off('message', onMessage); + worker.off('error', onError); + + release(worker); + task.reject(err); + }; + + worker.on('message', onMessage); + worker.on('error', onError); - /** - * Current diagnostics. - */ - public static stats() { - return { - total: this.workers.length, - available: this.available.length, - queued: this.queue.length, - }; + worker.postMessage({ + builderName: task.builderName, + payload: task.payload + }); +} + +/** + * Return a worker to the pool and drain the queue if tasks are waiting. + */ +function release(worker: Worker): void { + const next = queue.shift(); + if (next) { + execute(worker, next); + } else { + available.push(worker); } -} \ No newline at end of file +} + +/** + * Gracefully terminate all workers. Call during shutdown. + */ +export async function shutdown(): Promise { + const terminations = workers.map((w) => w.terminate()); + await Promise.all(terminations); + workers = []; + available = []; + queue = []; + isInitialized = false; + logger.info('[WorkerPool] All workers terminated'); +} + +/** + * Current diagnostics. + */ +export function stats(): Stats { + return { + total: workers.length, + available: available.length, + queued: queue.length + }; +} diff --git a/src/utilities/ZoneData.ts b/src/utilities/ZoneData.ts index c61a762..7b74be2 100644 --- a/src/utilities/ZoneData.ts +++ b/src/utilities/ZoneData.ts @@ -20,7 +20,7 @@ const TIER_NAMES: Record = { 2: 'The Adventurer', 3: 'The Hero', 4: 'The Ascendant', - 5: 'The Cosmic', + 5: 'The Cosmic' }; function getTier(zoneId: number): string { @@ -33,36 +33,217 @@ function getTier(zoneId: number): string { } export const ZONES: ZoneInfo[] = [ - { id: 1, name: 'Greenleaf Meadow', description: 'A safe haven for beginners. Slimes and lost trinkets abound.', levelReq: 1, tier: getTier(1), rarityCap: 'Uncommon', combatChance: 10, tollCost: 0 }, - { id: 2, name: 'Misty Creek', description: 'The fog hides goblins, but the river washes up gold.', levelReq: 5, tier: getTier(2), rarityCap: 'Uncommon', combatChance: 12, tollCost: 0 }, - { id: 3, name: "Bandit's Highway", description: 'A dangerous trade route. High risk, but the thieves are wealthy.', levelReq: 10, tier: getTier(3), rarityCap: 'Rare', combatChance: 18, tollCost: 0 }, - { id: 4, name: 'Whispering Woods', description: 'Ancient trees that hum with magic. Good for training.', levelReq: 15, tier: getTier(4), rarityCap: 'Rare', combatChance: 15, tollCost: 0 }, - { id: 5, name: 'Crumbling Ruins', description: 'The remains of an old kingdom. Undead guard the treasures.', levelReq: 25, tier: getTier(5), rarityCap: 'Elite', combatChance: 20, tollCost: 0 }, - { id: 6, name: 'Sunken Grotto', description: 'Damp, dark, and filled with glowing crystals.', levelReq: 35, tier: getTier(6), rarityCap: 'Elite', combatChance: 18, tollCost: 0 }, - { id: 7, name: 'Ironclad Fortress', description: 'A stronghold of elite soldiers. Brutal combat training.', levelReq: 50, tier: getTier(7), rarityCap: 'Epic', combatChance: 30, tollCost: 5 }, - { id: 8, name: 'Crystal Spire', description: 'A tower reaching for the heavens. The air hums with power.', levelReq: 75, tier: getTier(8), rarityCap: 'Epic', combatChance: 22, tollCost: 10 }, - { id: 9, name: 'Molten Core', description: 'The heat is unbearable. Only the strongest survive.', levelReq: 100, tier: getTier(9), rarityCap: 'Legendary', combatChance: 28, tollCost: 25 }, - { id: 10, name: "The Void's Edge", description: 'Reality flickers here. The loot is otherworldly.', levelReq: 150, tier: getTier(10), rarityCap: 'Legendary', combatChance: 35, tollCost: 50 }, - { id: 11, name: "Dragon's Fall", description: 'The impact site of the Great Fall. The source of all magic.', levelReq: 200, tier: getTier(11), rarityCap: 'Divine', combatChance: 45, tollCost: 100 }, - { id: 12, name: 'Plane of Eternal Fire', description: 'The ground itself is alive. Elementals roam freely.', levelReq: 250, tier: getTier(12), rarityCap: 'Divine', combatChance: 40, tollCost: 150 }, - { id: 13, name: 'Glacial Expanse', description: 'Time moves slower here. The cold stops the heart.', levelReq: 300, tier: getTier(13), rarityCap: 'Divine', combatChance: 42, tollCost: 200 }, - { id: 14, name: 'Thunderpeak', description: 'A mountain peak above the clouds. Storms never cease.', levelReq: 350, tier: getTier(14), rarityCap: 'Divine', combatChance: 45, tollCost: 300 }, - { id: 15, name: "Titan's Grave", description: 'Where the giants fell. Their bones form the landscape.', levelReq: 400, tier: getTier(15), rarityCap: 'Divine', combatChance: 48, tollCost: 400 }, - { id: 16, name: 'Stardust Sanctuary', description: 'Gravity is a suggestion here. Stars drift like sand.', levelReq: 500, tier: getTier(16), rarityCap: 'Divine', combatChance: 35, tollCost: 500 }, - { id: 17, name: 'Nebula of Souls', description: 'The spirits of the ancients watch your every step.', levelReq: 600, tier: getTier(17), rarityCap: 'Divine', combatChance: 40, tollCost: 650 }, - { id: 18, name: 'Black Hole Horizon', description: 'Light cannot escape. Hope struggles to survive.', levelReq: 700, tier: getTier(18), rarityCap: 'Divine', combatChance: 50, tollCost: 800 }, - { id: 19, name: "Creation's Forge", description: 'Where worlds are made and destroyed.', levelReq: 800, tier: getTier(19), rarityCap: 'Divine', combatChance: 55, tollCost: 1000 }, - { id: 20, name: 'The Absolute', description: 'The end of all things. The beginning of eternity.', levelReq: 900, tier: getTier(20), rarityCap: 'Divine', combatChance: 60, tollCost: 1200 }, + { + id: 1, + name: 'Greenleaf Meadow', + description: 'A safe haven for beginners. Slimes and lost trinkets abound.', + levelReq: 1, + tier: getTier(1), + rarityCap: 'Uncommon', + combatChance: 10, + tollCost: 0 + }, + { + id: 2, + name: 'Misty Creek', + description: 'The fog hides goblins, but the river washes up gold.', + levelReq: 5, + tier: getTier(2), + rarityCap: 'Uncommon', + combatChance: 12, + tollCost: 0 + }, + { + id: 3, + name: "Bandit's Highway", + description: + 'A dangerous trade route. High risk, but the thieves are wealthy.', + levelReq: 10, + tier: getTier(3), + rarityCap: 'Rare', + combatChance: 18, + tollCost: 0 + }, + { + id: 4, + name: 'Whispering Woods', + description: 'Ancient trees that hum with magic. Good for training.', + levelReq: 15, + tier: getTier(4), + rarityCap: 'Rare', + combatChance: 15, + tollCost: 0 + }, + { + id: 5, + name: 'Crumbling Ruins', + description: 'The remains of an old kingdom. Undead guard the treasures.', + levelReq: 25, + tier: getTier(5), + rarityCap: 'Elite', + combatChance: 20, + tollCost: 0 + }, + { + id: 6, + name: 'Sunken Grotto', + description: 'Damp, dark, and filled with glowing crystals.', + levelReq: 35, + tier: getTier(6), + rarityCap: 'Elite', + combatChance: 18, + tollCost: 0 + }, + { + id: 7, + name: 'Ironclad Fortress', + description: 'A stronghold of elite soldiers. Brutal combat training.', + levelReq: 50, + tier: getTier(7), + rarityCap: 'Epic', + combatChance: 30, + tollCost: 5 + }, + { + id: 8, + name: 'Crystal Spire', + description: 'A tower reaching for the heavens. The air hums with power.', + levelReq: 75, + tier: getTier(8), + rarityCap: 'Epic', + combatChance: 22, + tollCost: 10 + }, + { + id: 9, + name: 'Molten Core', + description: 'The heat is unbearable. Only the strongest survive.', + levelReq: 100, + tier: getTier(9), + rarityCap: 'Legendary', + combatChance: 28, + tollCost: 25 + }, + { + id: 10, + name: "The Void's Edge", + description: 'Reality flickers here. The loot is otherworldly.', + levelReq: 150, + tier: getTier(10), + rarityCap: 'Legendary', + combatChance: 35, + tollCost: 50 + }, + { + id: 11, + name: "Dragon's Fall", + description: 'The impact site of the Great Fall. The source of all magic.', + levelReq: 200, + tier: getTier(11), + rarityCap: 'Divine', + combatChance: 45, + tollCost: 100 + }, + { + id: 12, + name: 'Plane of Eternal Fire', + description: 'The ground itself is alive. Elementals roam freely.', + levelReq: 250, + tier: getTier(12), + rarityCap: 'Divine', + combatChance: 40, + tollCost: 150 + }, + { + id: 13, + name: 'Glacial Expanse', + description: 'Time moves slower here. The cold stops the heart.', + levelReq: 300, + tier: getTier(13), + rarityCap: 'Divine', + combatChance: 42, + tollCost: 200 + }, + { + id: 14, + name: 'Thunderpeak', + description: 'A mountain peak above the clouds. Storms never cease.', + levelReq: 350, + tier: getTier(14), + rarityCap: 'Divine', + combatChance: 45, + tollCost: 300 + }, + { + id: 15, + name: "Titan's Grave", + description: 'Where the giants fell. Their bones form the landscape.', + levelReq: 400, + tier: getTier(15), + rarityCap: 'Divine', + combatChance: 48, + tollCost: 400 + }, + { + id: 16, + name: 'Stardust Sanctuary', + description: 'Gravity is a suggestion here. Stars drift like sand.', + levelReq: 500, + tier: getTier(16), + rarityCap: 'Divine', + combatChance: 35, + tollCost: 500 + }, + { + id: 17, + name: 'Nebula of Souls', + description: 'The spirits of the ancients watch your every step.', + levelReq: 600, + tier: getTier(17), + rarityCap: 'Divine', + combatChance: 40, + tollCost: 650 + }, + { + id: 18, + name: 'Black Hole Horizon', + description: 'Light cannot escape. Hope struggles to survive.', + levelReq: 700, + tier: getTier(18), + rarityCap: 'Divine', + combatChance: 50, + tollCost: 800 + }, + { + id: 19, + name: "Creation's Forge", + description: 'Where worlds are made and destroyed.', + levelReq: 800, + tier: getTier(19), + rarityCap: 'Divine', + combatChance: 55, + tollCost: 1000 + }, + { + id: 20, + name: 'The Absolute', + description: 'The end of all things. The beginning of eternity.', + levelReq: 900, + tier: getTier(20), + rarityCap: 'Divine', + combatChance: 60, + tollCost: 1200 + } ]; export function getZone(id: number): ZoneInfo | undefined { - return ZONES.find(z => z.id === id); + return ZONES.find((z) => z.id === id); } export function getAccessibleZones(playerLevel: number): ZoneInfo[] { - return ZONES.filter(z => playerLevel >= z.levelReq); + return ZONES.filter((z) => playerLevel >= z.levelReq); } export function getAllZones(): ZoneInfo[] { return ZONES; -} \ No newline at end of file +} diff --git a/src/utilities/helpers.ts b/src/utilities/helpers.ts new file mode 100644 index 0000000..db6b5f5 --- /dev/null +++ b/src/utilities/helpers.ts @@ -0,0 +1,7 @@ +export function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +}