From f4be685a8215a943704d3961c06db90667ff219b Mon Sep 17 00:00:00 2001 From: Niek Candaele <22315101+niekcandaele@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:35:10 +0200 Subject: [PATCH] feat: turn this repo into the first subscribable Takaro module registry Implements Phase 4 of Takaro PR #2723 (subscribable module registries). Generates a registry.json manifest plus per-version JSON for each module into dist/registry/ via build-registry.ts, and publishes the tree to a `registry` branch on every push to main via a new GitHub Actions workflow. Module changes: - All 11 module.json files: name normalized to match directory (no test- prefix); version bumped from "latest" to "1.0.0" (registry requires semver) - modules/lottery/ -> modules/jackpot/ (collided with Takaro built-in lottery); internal renames cascade to commands, permissions (LOTTERY_* -> JACKPOT_*), function file (lottery-helpers.js -> jackpot-helpers.js), storage keys, log prefixes, test assertions - modules/utils/ -> modules/server-toolkit/ (collided with built-in utils); same cascade: TOOLKIT_* permissions, server-toolkit-{helpers,pure}.js, log prefixes, test assertions Infrastructure: - src/scripts/build-registry.ts (new): validates module names against the PR's manifest schema (regex/semver/length), writes dist/registry/ tree, clears stale artifacts before each build - src/scripts/module-to-json.ts: refactored to expose buildModuleExport() function for reuse; safeRealpath() helper unifies broken-symlink errors - src/utils/validate.ts: validateEntityName now throws (was process.exit) - .github/workflows/publish-registry.yml (new): publishes to `registry` branch via peaceiris/actions-gh-pages; concurrency guard prevents overlapping pushes; force_orphan documented - test/helpers/modules.ts: assertTestSafeHost() guard refuses to run pushModule/cleanupTestModules against production-looking hosts; cleanup discovers module names from filesystem (no manual list) - test/helpers/modules-guard.test.ts (new): 12 unit tests for the guard; package.json test glob expanded to pick up test/helpers/*.test.ts Documentation: - AGENTS.md: module conventions (semver bump on change, name=directory, Takaro UI shows name verbatim), safety guard reference with full error template + numbered fix list + auto-accepted hosts list - README.md: Published Registry section with public URL, status note about pending Takaro-side content-type fix, and dev local-serve workflow; warning that npm test refuses production hosts - testing-methodology.md skill ref: updated to reflect new naming rule --- .../references/testing-methodology.md | 6 +- .github/workflows/publish-registry.yml | 58 +++ AGENTS.md | 48 +++ README.md | 37 ++ modules/afk-kick/module.json | 6 +- modules/community-fund/module.json | 6 +- modules/daily-rewards/module.json | 14 +- modules/hello-world/module.json | 8 +- modules/{lottery => jackpot}/module.json | 46 +-- .../src/commands/buy-ticket/index.js | 12 +- .../src/commands/jackpot-info}/index.js | 12 +- .../src/commands/next-draw/index.js | 2 +- .../src/commands/view-tickets/index.js | 6 +- .../src/cronjobs/draw-jackpot}/index.js | 38 +- .../src/functions/jackpot-helpers.js} | 46 +-- .../test/buy-ticket.test.ts | 18 +- .../test/draw-jackpot-rollover.test.ts} | 6 +- .../test/draw-jackpot.test.ts} | 12 +- .../test/jackpot-info.test.ts} | 20 +- .../test/next-draw.test.ts | 2 +- .../test/view-tickets.test.ts | 14 +- modules/kill-tracker/module.json | 6 +- modules/mini-games/module.json | 106 +++-- modules/referral-program/module.json | 16 +- modules/server-messages/module.json | 13 +- modules/{utils => server-toolkit}/module.json | 18 +- .../src/commands/ban/index.js | 16 +- .../src/commands/discord/index.js | 6 +- .../src/commands/givecurrency/index.js | 12 +- .../src/commands/kick/index.js | 14 +- .../src/commands/online/index.js | 6 +- .../src/commands/rules/index.js | 6 +- .../src/commands/serverinfo/index.js | 6 +- .../src/functions/server-toolkit-helpers.js} | 6 +- .../src/functions/server-toolkit-pure.d.ts} | 0 .../src/functions/server-toolkit-pure.js} | 0 .../test/server-toolkit.test.ts} | 66 ++-- modules/vote-restart/module.json | 6 +- package.json | 7 +- registry.config.json | 4 + src/scripts/build-registry.ts | 163 ++++++++ src/scripts/json-to-module.ts | 66 ++-- src/scripts/module-to-json.ts | 368 ++++++++++-------- src/types/module.ts | 15 + src/utils/validate.ts | 8 +- test/helpers/modules-guard.test.ts | 154 ++++++++ test/helpers/modules.ts | 79 +++- 47 files changed, 1102 insertions(+), 482 deletions(-) create mode 100644 .github/workflows/publish-registry.yml rename modules/{lottery => jackpot}/module.json (66%) rename modules/{lottery => jackpot}/src/commands/buy-ticket/index.js (87%) rename modules/{lottery/src/commands/lottery-info => jackpot/src/commands/jackpot-info}/index.js (71%) rename modules/{lottery => jackpot}/src/commands/next-draw/index.js (90%) rename modules/{lottery => jackpot}/src/commands/view-tickets/index.js (83%) rename modules/{lottery/src/cronjobs/draw-lottery => jackpot/src/cronjobs/draw-jackpot}/index.js (84%) rename modules/{lottery/src/functions/lottery-helpers.js => jackpot/src/functions/jackpot-helpers.js} (77%) rename modules/{lottery => jackpot}/test/buy-ticket.test.ts (95%) rename modules/{lottery/test/draw-lottery-rollover.test.ts => jackpot/test/draw-jackpot-rollover.test.ts} (98%) rename modules/{lottery/test/draw-lottery.test.ts => jackpot/test/draw-jackpot.test.ts} (97%) rename modules/{lottery/test/lottery-info.test.ts => jackpot/test/jackpot-info.test.ts} (91%) rename modules/{lottery => jackpot}/test/next-draw.test.ts (98%) rename modules/{lottery => jackpot}/test/view-tickets.test.ts (93%) rename modules/{utils => server-toolkit}/module.json (94%) rename modules/{utils => server-toolkit}/src/commands/ban/index.js (83%) rename modules/{utils => server-toolkit}/src/commands/discord/index.js (74%) rename modules/{utils => server-toolkit}/src/commands/givecurrency/index.js (80%) rename modules/{utils => server-toolkit}/src/commands/kick/index.js (81%) rename modules/{utils => server-toolkit}/src/commands/online/index.js (60%) rename modules/{utils => server-toolkit}/src/commands/rules/index.js (77%) rename modules/{utils => server-toolkit}/src/commands/serverinfo/index.js (75%) rename modules/{utils/src/functions/utils-helpers.js => server-toolkit/src/functions/server-toolkit-helpers.js} (92%) rename modules/{utils/src/functions/utils-pure.d.ts => server-toolkit/src/functions/server-toolkit-pure.d.ts} (100%) rename modules/{utils/src/functions/utils-pure.js => server-toolkit/src/functions/server-toolkit-pure.js} (100%) rename modules/{utils/test/utils.test.ts => server-toolkit/test/server-toolkit.test.ts} (95%) create mode 100644 registry.config.json create mode 100644 src/scripts/build-registry.ts create mode 100644 test/helpers/modules-guard.test.ts diff --git a/.claude/skills/takaro-module-dev/references/testing-methodology.md b/.claude/skills/takaro-module-dev/references/testing-methodology.md index bb41f9c..43286ea 100644 --- a/.claude/skills/takaro-module-dev/references/testing-methodology.md +++ b/.claude/skills/takaro-module-dev/references/testing-methodology.md @@ -112,7 +112,7 @@ const prefix = await getCommandPrefix(client, gameServerId); await uninstallModule(client, moduleId, gameServerId); await deleteModule(client, moduleId); -// Safety net: delete orphaned test-* modules and game servers +// Safety net: delete orphaned modules (matching modules/ directory listing) and game servers await cleanupTestModules(client); await cleanupTestGameServers(client); ``` @@ -121,10 +121,10 @@ await cleanupTestGameServers(client); When tests crash before `after()` runs, mock game servers and test modules are left behind in Takaro. Both cleanup functions should be called in `before()` hooks: -- `cleanupTestModules(client)` — deletes modules with names starting with `test-` +- `cleanupTestModules(client)` — deletes modules whose names match a directory under `modules/` - `cleanupTestGameServers(client)` — deletes game servers with names starting with `test-` -Module names should start with `test-` (e.g., `test-hello-world` in `module.json`) so `cleanupTestModules` can find and remove them. +Module names must match their directory name (e.g., for `modules/hello-world/`, set `"name": "hello-world"` in module.json — this is enforced by both the test cleanup helper and the registry build). ### Writing Tests for a New Module diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml new file mode 100644 index 0000000..71ec92d --- /dev/null +++ b/.github/workflows/publish-registry.yml @@ -0,0 +1,58 @@ +name: Publish Registry + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: publish-registry + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Login to CodeArtifact + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_CODEARTIFACT_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_CODEARTIFACT_SECRET_ACCESS_KEY }} + run: | + TOKEN=$(aws codeartifact get-authorization-token \ + --domain takaro --domain-owner 967985385710 \ + --region eu-west-3 --query authorizationToken --output text) + REGISTRY=takaro-967985385710.d.codeartifact.eu-west-3.amazonaws.com + npm config set //${REGISTRY}/npm/takaro-private/:_authToken=$TOKEN + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Build TypeScript + run: npm run build + + - name: Build registry + run: npm run build:registry + + - name: Publish to registry branch + # force_orphan: true — the registry branch carries no commit history. + # Each push to main produces a clean snapshot of the current registry state. + # We intentionally accept this loss of history pre-1.0.1: the registry branch + # is purely a distribution artifact, not a source branch, so history adds no value. + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist/registry + publish_branch: registry + force_orphan: true + keep_files: false diff --git a/AGENTS.md b/AGENTS.md index df3392f..957b134 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,54 @@ Module code lives locally in `modules/` as editable files, then gets pushed to T **Why**: Mocked tests pass while the real system fails. The whole point of this test infrastructure is to catch integration issues (wrong API shapes, missing permissions, runtime errors in the Takaro function sandbox). Unit tests with mocked helpers bypass all of that. +## Module Conventions + +### Versioning + +**Every change to a module requires a version bump in `module.json`.** Versions must be valid semver (e.g. `1.0.0`, `1.0.1`, `2.1.0`). The value `"latest"` is not accepted by the registry. + +### Module naming + +Module names must match their directory name under `modules/`. Names may only contain `[a-zA-Z0-9_-]` (no spaces, no dots, no slashes). Maximum 100 characters. + +For example: the module in `modules/afk-kick/` must have `"name": "afk-kick"` in its `module.json`. + +> **Known limitation**: The `name` field appears literally in the Takaro UI as the module identifier (there is no separate `displayName` field today). Choose names that are readable as-is — kebab-case slugs like `afk-kick` or `mini-games` display acceptably. This is a Takaro platform constraint that may be relaxed in a future version. + +### Adding a new module + +1. Create `modules//module.json` with `"name": ""` and `"version": "1.0.0"`. +2. Run `npm run build && npm run build:registry` to verify the registry builds cleanly. +3. Commit and push — the publish workflow updates the `registry` branch automatically. + +`cleanupTestModules` discovers module names from the `modules/` directory at runtime — no manual list to update when adding a new module. + +See `src/scripts/build-registry.ts` for the registry build logic. + +### Safety guard for test helpers + +`pushModule` and `cleanupTestModules` refuse to touch a Takaro instance whose `TAKARO_HOST` doesn't look like a test/dev environment. This prevents accidentally wiping production modules when running tests. + +**If you see this error:** +``` +pushModule/cleanupTestModules refused: TAKARO_HOST='' does not match any known test/dev host pattern. ... +``` + +**Common fixes (try in this order):** +1. Check `TAKARO_HOST` in `.env` is correct — pointing at production (`api.takaro.io`) is the most common cause. +2. If your dev/staging host has a non-standard hostname, add a substring to `TAKARO_TEST_HOST_ALLOWLIST` (comma-separated) in `.env`. +3. Last resort, for a single throwaway run: `TAKARO_TEST_ALLOW_ANY_HOST=1 npm test`. Use only when you're certain the target is not shared production state. + +**Auto-accepted hosts:** `localhost`, `127.0.0.1`, `host.docker.internal`, single-label Docker hostnames (e.g. `takaro_api`), `*.takaro.dev`, `.staging.`/`.dev.`/`.test.`/`.ci.` substrings, `.test`/`.localhost` TLDs. + +**CI:** uses `localhost:13000` and passes the guard automatically. + +### Test pretest blocking + +`npm test` runs `build:registry` as a `pretest` step. If any module fails registry validation (bad semver, invalid name, description too long), the entire test run is blocked — including unrelated modules. To unblock: fix the validation error in the offending module, or temporarily move the module directory out of `modules/` while debugging other tests. + +Workaround: bypass the pretest gate with `node --test --import=ts-node-maintained/register/esm 'modules//test/*.test.ts'`. + ## Available Tools - **`scripts/takaro-auth.sh`** — Authenticate with the Takaro API diff --git a/README.md b/README.md index 4e34a2c..ccd38a5 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Each clone also needs a unique `TAKARO_MC_IDENTITY_TOKEN` in `.env`. You need: - `TAKARO_USERNAME` and `TAKARO_PASSWORD` — Your Takaro account credentials - `TAKARO_HOST` — The Takaro API URL (e.g., `https://api.takaro.io`) + + Note: `npm test` will refuse to run against production hosts (e.g. `api.takaro.io`). Use a dev/staging host or see [AGENTS.md](AGENTS.md#safety-guard-for-test-helpers) for the bypass. - `TAKARO_DOMAIN_ID` — Your Takaro domain ID ### Minecraft Server Setup @@ -85,6 +87,41 @@ claude > Write me a module that says 'hello' to every player when they join ``` +## Published Registry + +This repository is the **Takaro Official Module Registry**. Modules are automatically published to the `registry` branch on every push to `main`. + +> **Status — end-to-end subscription currently blocked:** `raw.githubusercontent.com` serves `.json` files with `Content-Type: text/plain`, but the Takaro registry client requires `Content-Type` to contain `"json"`. This is a Takaro server-side limitation; subscriptions will work automatically once the next Takaro release ships the content-type fix (see [gettakaro/takaro#2723](https://github.com/gettakaro/takaro/pull/2723)). The `registry` branch is published and up to date in the meantime. + +**Public registry URL** (usable once the Takaro-side fix ships): + +``` +https://raw.githubusercontent.com/gettakaro/ai-module-writer/registry +``` + +To subscribe in Takaro, navigate to **Modules → Registries → Add Registry** and enter the URL above. Note: subscription will return an error until the content-type fix ships. + +### Local testing (developer workaround) + +While the production URL is blocked, developers can test the registry locally: + +```bash +npm run build && npm run build:registry +python3 -m http.server 8765 --directory dist/registry +``` + +Then subscribe to `http://host.docker.internal:8765` from a local Takaro dev stack with `TAKARO_REGISTRY_ALLOWED_PRIVATE_HOSTS` set to include `host.docker.internal`. + +### Versioning convention + +Any change to a module **requires a version bump** in that module's `module.json`. Module versions must be valid semver (e.g. `1.0.1`). Module names must match their directory name and may only contain `[a-zA-Z0-9_-]`. + +### Adding a new module + +1. Create `modules//module.json` with `"name": ""` and `"version": "1.0.0"`. +2. Run `npm run build && npm run build:registry` to verify the registry builds. +3. Push to `main` — the publish workflow will update the `registry` branch automatically. + ## How it Works The Docker containers run: diff --git a/modules/afk-kick/module.json b/modules/afk-kick/module.json index 1a318b5..5cb6eb1 100644 --- a/modules/afk-kick/module.json +++ b/modules/afk-kick/module.json @@ -1,7 +1,7 @@ { - "name": "test-afk-kick", - "description": "AFK Kick — periodically checks player positions and warns/kicks idle players. A permission exempts specific players from AFK detection.", - "version": "latest", + "name": "afk-kick", + "description": "AFK Kick \u2014 periodically checks player positions and warns/kicks idle players. A permission exempts specific players from AFK detection.", + "version": "1.0.0", "supportedGames": [ "all" ], diff --git a/modules/community-fund/module.json b/modules/community-fund/module.json index 5a112a8..afccc55 100644 --- a/modules/community-fund/module.json +++ b/modules/community-fund/module.json @@ -1,7 +1,7 @@ { - "name": "test-community-fund", - "description": "Community Fund — players contribute currency toward a shared goal. When the threshold is reached the fund resets and completion commands fire.", - "version": "latest", + "name": "community-fund", + "description": "Community Fund \u2014 players contribute currency toward a shared goal. When the threshold is reached the fund resets and completion commands fire.", + "version": "1.0.0", "supportedGames": [ "all" ], diff --git a/modules/daily-rewards/module.json b/modules/daily-rewards/module.json index 8c328d3..c5624e3 100644 --- a/modules/daily-rewards/module.json +++ b/modules/daily-rewards/module.json @@ -1,7 +1,7 @@ { - "name": "test-daily-rewards", + "name": "daily-rewards", "description": "Daily login reward system with streak tracking, milestone bonuses, and role-based multipliers.", - "version": "latest", + "version": "1.0.0", "supportedGames": [ "all" ], @@ -27,22 +27,22 @@ { "days": 7, "reward": 1000, - "message": "🎉 Week streak! Bonus: 1000 coins!" + "message": "\ud83c\udf89 Week streak! Bonus: 1000 coins!" }, { "days": 30, "reward": 5000, - "message": "🏆 Monthly streak! Bonus: 5000 coins!" + "message": "\ud83c\udfc6 Monthly streak! Bonus: 5000 coins!" }, { "days": 100, "reward": 20000, - "message": "🌟 100 day streak! Legendary Bonus: 20000 coins!" + "message": "\ud83c\udf1f 100 day streak! Legendary Bonus: 20000 coins!" }, { "days": 365, "reward": 100000, - "message": "👑 365 day streak! You are a legend! Bonus: 100000 coins!" + "message": "\ud83d\udc51 365 day streak! You are a legend! Bonus: 100000 coins!" } ], "items": { @@ -72,7 +72,7 @@ "type": "number", "default": 48, "minimum": 25, - "description": "Hours after which a streak expires (must be >24 so players can always reclaim before expiry). Setting this to 25 means players have only 1 hour after the 24h cooldown before their streak expires — effectively always at-risk. The default of 48h gives a comfortable window." + "description": "Hours after which a streak expires (must be >24 so players can always reclaim before expiry). Setting this to 25 means players have only 1 hour after the 24h cooldown before their streak expires \u2014 effectively always at-risk. The default of 48h gives a comfortable window." }, "notifyOnLogin": { "type": "boolean", diff --git a/modules/hello-world/module.json b/modules/hello-world/module.json index 96d39c9..66a6ece 100644 --- a/modules/hello-world/module.json +++ b/modules/hello-world/module.json @@ -1,7 +1,7 @@ { - "name": "test-hello-world", - "description": "A simple hello world module for testing", - "version": "latest", + "name": "hello-world", + "description": "Greets players in chat with a configurable hello message when they join or use /greet.", + "version": "1.0.0", "supportedGames": [ "all" ], @@ -9,7 +9,7 @@ "greet": { "trigger": "greet", "description": "Greet a player by name", - "helpText": "Usage: /greet [name] — Greets you or the specified name", + "helpText": "Usage: /greet [name] \u2014 Greets you or the specified name", "function": "src/commands/greet/index.js", "arguments": [ { diff --git a/modules/lottery/module.json b/modules/jackpot/module.json similarity index 66% rename from modules/lottery/module.json rename to modules/jackpot/module.json index f2e4e47..64a76b7 100644 --- a/modules/lottery/module.json +++ b/modules/jackpot/module.json @@ -1,7 +1,7 @@ { - "name": "test-lottery", - "description": "Lottery — players buy tickets with in-game currency and a daily draw picks a weighted random winner. Prize pool grows with each ticket purchase.", - "version": "latest", + "name": "jackpot", + "description": "Players buy tickets with in-game currency and a daily draw picks a winner — more tickets = better odds. Prize pool grows with each ticket purchase.", + "version": "1.0.0", "supportedGames": [ "all" ], @@ -50,23 +50,23 @@ }, "permissions": [ { - "permission": "LOTTERY_BUY", - "friendlyName": "Buy Lottery Tickets", - "description": "Allows a player to buy lottery tickets", + "permission": "JACKPOT_BUY", + "friendlyName": "Buy Jackpot Tickets", + "description": "Allows a player to buy jackpot tickets", "canHaveCount": false }, { - "permission": "LOTTERY_VIEW_TICKETS", - "friendlyName": "View Lottery Tickets", - "description": "Allows a player to view their current ticket count", + "permission": "JACKPOT_VIEW_TICKETS", + "friendlyName": "View Jackpot Tickets", + "description": "Allows a player to view their current jackpot ticket count", "canHaveCount": false } ], "commands": { "buy-ticket": { "trigger": "buyticket", - "description": "Buy lottery tickets", - "helpText": "Buy one or more lottery tickets for the current draw. Usage: buyticket ", + "description": "Buy jackpot tickets", + "helpText": "Buy one or more jackpot tickets for the current draw. Usage: buyticket ", "function": "src/commands/buy-ticket/index.js", "arguments": [ { @@ -77,38 +77,38 @@ } ] }, - "lottery-info": { - "trigger": "lotteryinfo", - "description": "View current lottery status", + "jackpot-info": { + "trigger": "jackpotinfo", + "description": "View current jackpot status", "helpText": "Shows the current pot, participant count, total tickets, and ticket price for the upcoming draw.", - "function": "src/commands/lottery-info/index.js", + "function": "src/commands/jackpot-info/index.js", "arguments": [] }, "next-draw": { "trigger": "nextdraw", - "description": "See when the next lottery draw is", - "helpText": "Shows how long until the next lottery draw at midnight UTC.", + "description": "See when the next jackpot draw is", + "helpText": "Shows how long until the next jackpot draw at midnight UTC.", "function": "src/commands/next-draw/index.js", "arguments": [] }, "view-tickets": { "trigger": "viewtickets", - "description": "View your current lottery ticket count", + "description": "View your current jackpot ticket count", "helpText": "Shows how many tickets you currently hold for the upcoming draw.", "function": "src/commands/view-tickets/index.js", "arguments": [] } }, "cronJobs": { - "draw-lottery": { + "draw-jackpot": { "temporalValue": "0 0 * * *", - "description": "Daily lottery draw at midnight UTC — picks a weighted random winner from ticket holders", - "function": "src/cronjobs/draw-lottery/index.js" + "description": "Daily jackpot draw at midnight UTC — picks a winner weighted by ticket count", + "function": "src/cronjobs/draw-jackpot/index.js" } }, "functions": { - "lottery-helpers": { - "function": "src/functions/lottery-helpers.js" + "jackpot-helpers": { + "function": "src/functions/jackpot-helpers.js" } } } diff --git a/modules/lottery/src/commands/buy-ticket/index.js b/modules/jackpot/src/commands/buy-ticket/index.js similarity index 87% rename from modules/lottery/src/commands/buy-ticket/index.js rename to modules/jackpot/src/commands/buy-ticket/index.js index 70eecdd..5d8d412 100644 --- a/modules/lottery/src/commands/buy-ticket/index.js +++ b/modules/jackpot/src/commands/buy-ticket/index.js @@ -4,13 +4,13 @@ import { setPot, getPlayerTickets, setPlayerTickets, -} from './lottery-helpers.js'; +} from './jackpot-helpers.js'; async function main() { const { pog, player, gameServerId, arguments: args, module: mod } = data; - if (!checkPermission(pog, 'LOTTERY_BUY')) { - throw new TakaroUserError('You do not have permission to buy lottery tickets.'); + if (!checkPermission(pog, 'JACKPOT_BUY')) { + throw new TakaroUserError('You do not have permission to buy jackpot tickets.'); } const amount = args.amount; @@ -58,7 +58,7 @@ async function main() { return true; } catch (deductErr) { console.error( - `Lottery: currency deduction failed for player ${player.name} (additionalCost=${deductAmount}). Pot and tickets already updated. Error: ${deductErr}`, + `jackpot-buy: currency deduction failed for player ${player.name} (additionalCost=${deductAmount}). Pot and tickets already updated. Error: ${deductErr}`, ); return false; } @@ -71,7 +71,7 @@ async function main() { const newPot = currentPot + amount * ticketPrice; console.log( - `Lottery: player=${player.name}, buying ${amount} tickets, currentTickets=${currentTickets}, newTickets=${newTickets}, previousPot=${currentPot}, newPot=${newPot}, totalCost=${totalCost}`, + `jackpot-buy: player=${player.name}, buying ${amount} tickets, currentTickets=${currentTickets}, newTickets=${newTickets}, previousPot=${currentPot}, newPot=${newPot}, totalCost=${totalCost}`, ); await setPlayerTickets(gameServerId, moduleId, pog.playerId, newTickets); @@ -90,7 +90,7 @@ async function main() { if (config.announceTicketPurchases) { await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { - message: `${player.name} bought ${amount} lottery ticket${amount > 1 ? 's' : ''}! Current pot: ${newPot}.`, + message: `${player.name} bought ${amount} jackpot ticket${amount > 1 ? 's' : ''}! Current pot: ${newPot}.`, opts: {}, }); } diff --git a/modules/lottery/src/commands/lottery-info/index.js b/modules/jackpot/src/commands/jackpot-info/index.js similarity index 71% rename from modules/lottery/src/commands/lottery-info/index.js rename to modules/jackpot/src/commands/jackpot-info/index.js index 47e2387..0878e76 100644 --- a/modules/lottery/src/commands/lottery-info/index.js +++ b/modules/jackpot/src/commands/jackpot-info/index.js @@ -1,5 +1,5 @@ import { data } from '@takaro/helpers'; -import { getPot, getRollover, getAllTicketEntries } from './lottery-helpers.js'; +import { getPot, getRollover, getAllTicketEntries } from './jackpot-helpers.js'; async function main() { const { pog, gameServerId, module: mod } = data; @@ -17,16 +17,16 @@ async function main() { const totalTickets = entries.reduce((sum, e) => sum + e.tickets, 0); const totalPot = pot + rollover; - console.log(`lottery-info: pot=${pot}, rollover=${rollover}, participants=${participantCount}, totalTickets=${totalTickets}`); + console.log(`jackpot-info: pot=${pot}, rollover=${rollover}, participants=${participantCount}, totalTickets=${totalTickets}`); if (participantCount === 0) { - await pog.pm(`Lottery: No participants yet. Ticket price: ${config.ticketPrice}. Use the buyticket command to participate!`); + await pog.pm(`Jackpot: No participants yet. Ticket price: ${config.ticketPrice}. Use the buyticket command to participate!`); } else { const estimatedPrize = Math.floor(totalPot * (1 - config.profitMargin)); // Lead with the prize, then details - await pog.pm(`Lottery: Estimated prize this draw: ${estimatedPrize} currency.`); - await pog.pm(`Lottery: Pot: ${totalPot}${rollover > 0 ? ` (incl. ${rollover} rollover)` : ''} | Participants: ${participantCount} | Total tickets: ${totalTickets} | Ticket price: ${config.ticketPrice}.`); - await pog.pm(`Lottery: The draw runs on a scheduled timer.`); + await pog.pm(`Jackpot: Estimated prize this draw: ${estimatedPrize} currency.`); + await pog.pm(`Jackpot: Pot: ${totalPot}${rollover > 0 ? ` (incl. ${rollover} rollover)` : ''} | Participants: ${participantCount} | Total tickets: ${totalTickets} | Ticket price: ${config.ticketPrice}.`); + await pog.pm(`Jackpot: The draw runs on a scheduled timer.`); } } diff --git a/modules/lottery/src/commands/next-draw/index.js b/modules/jackpot/src/commands/next-draw/index.js similarity index 90% rename from modules/lottery/src/commands/next-draw/index.js rename to modules/jackpot/src/commands/next-draw/index.js index 7af87fa..b274e62 100644 --- a/modules/lottery/src/commands/next-draw/index.js +++ b/modules/jackpot/src/commands/next-draw/index.js @@ -18,7 +18,7 @@ async function main() { console.log(`nextdraw: hours=${hours}, minutes=${minutes}`); - await pog.pm(`Next lottery draw in ${hours} hour${hours !== 1 ? 's' : ''} and ${minutes} minute${minutes !== 1 ? 's' : ''}.`); + await pog.pm(`Next jackpot draw in ${hours} hour${hours !== 1 ? 's' : ''} and ${minutes} minute${minutes !== 1 ? 's' : ''}.`); } await main(); diff --git a/modules/lottery/src/commands/view-tickets/index.js b/modules/jackpot/src/commands/view-tickets/index.js similarity index 83% rename from modules/lottery/src/commands/view-tickets/index.js rename to modules/jackpot/src/commands/view-tickets/index.js index 1f497dd..e2761c0 100644 --- a/modules/lottery/src/commands/view-tickets/index.js +++ b/modules/jackpot/src/commands/view-tickets/index.js @@ -1,11 +1,11 @@ import { data, TakaroUserError, checkPermission } from '@takaro/helpers'; -import { getPlayerTickets } from './lottery-helpers.js'; +import { getPlayerTickets } from './jackpot-helpers.js'; async function main() { const { pog, gameServerId, module: mod } = data; - if (!checkPermission(pog, 'LOTTERY_VIEW_TICKETS')) { - throw new TakaroUserError('You do not have permission to view lottery tickets.'); + if (!checkPermission(pog, 'JACKPOT_VIEW_TICKETS')) { + throw new TakaroUserError('You do not have permission to view jackpot tickets.'); } const moduleId = mod.moduleId; diff --git a/modules/lottery/src/cronjobs/draw-lottery/index.js b/modules/jackpot/src/cronjobs/draw-jackpot/index.js similarity index 84% rename from modules/lottery/src/cronjobs/draw-lottery/index.js rename to modules/jackpot/src/cronjobs/draw-jackpot/index.js index 713f198..4db258b 100644 --- a/modules/lottery/src/cronjobs/draw-lottery/index.js +++ b/modules/jackpot/src/cronjobs/draw-jackpot/index.js @@ -8,7 +8,7 @@ import { setDrawNumber, getAllTicketEntries, deleteAllTickets, -} from './lottery-helpers.js'; +} from './jackpot-helpers.js'; async function main() { const { gameServerId, module: mod } = data; @@ -27,12 +27,12 @@ async function main() { const totalPot = pot + rollover; console.log( - `draw-lottery: drawNumber=${drawNumber}, participants=${participantCount}, totalTickets=${totalTickets}, pot=${pot}, rollover=${rollover}, totalPot=${totalPot}`, + `jackpot-cron: drawNumber=${drawNumber}, participants=${participantCount}, totalTickets=${totalTickets}, pot=${pot}, rollover=${rollover}, totalPot=${totalPot}`, ); if (participantCount < config.minimumParticipants) { console.log( - `draw-lottery: cancelling draw — only ${participantCount} participant(s), need ${config.minimumParticipants}`, + `jackpot-cron: cancelling draw — only ${participantCount} participant(s), need ${config.minimumParticipants}`, ); if (config.rolloverOnCancel) { @@ -42,7 +42,7 @@ async function main() { await deleteAllTickets(gameServerId, moduleId); await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { - message: `The lottery draw was cancelled — not enough participants (need ${config.minimumParticipants}, got ${participantCount}). The pot of ${totalPot} rolls over to the next draw! Tickets do not carry over — buy new tickets for the next draw!`, + message: `The jackpot draw was cancelled — not enough participants (need ${config.minimumParticipants}, got ${participantCount}). The pot of ${totalPot} rolls over to the next draw! Tickets do not carry over — buy new tickets for the next draw!`, opts: {}, }); } else { @@ -57,11 +57,11 @@ async function main() { currency: refund, }); totalRefunded += refund; - console.log(`draw-lottery: refunded ${refund} to player ${entry.playerId}`); + console.log(`jackpot-cron: refunded ${refund} to player ${entry.playerId}`); } catch (refundErr) { refundFailures++; failedPlayerIds.push(entry.playerId); - console.error(`draw-lottery: failed to refund player ${entry.playerId} (refund=${refund}). Error: ${refundErr}`); + console.error(`jackpot-cron: failed to refund player ${entry.playerId} (refund=${refund}). Error: ${refundErr}`); } } @@ -72,7 +72,7 @@ async function main() { await deleteAllTickets(gameServerId, moduleId); await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { - message: `The lottery draw was cancelled — not enough participants (need ${config.minimumParticipants}, got ${participantCount}). All tickets have been refunded.`, + message: `The jackpot draw was cancelled — not enough participants (need ${config.minimumParticipants}, got ${participantCount}). All tickets have been refunded.`, opts: {}, }); } else { @@ -81,10 +81,10 @@ async function main() { const remainingPot = totalPot - totalRefunded; await setRollover(gameServerId, moduleId, 0); await setPot(gameServerId, moduleId, remainingPot); - console.error(`draw-lottery: ${refundFailures} refund(s) failed for players: ${failedPlayerIds.join(', ')}. Pot reduced by ${totalRefunded} (refunded amount). Remaining pot: ${remainingPot}. Ticket records preserved for manual recovery.`); + console.error(`jackpot-cron: ${refundFailures} refund(s) failed for players: ${failedPlayerIds.join(', ')}. Pot reduced by ${totalRefunded} (refunded amount). Remaining pot: ${remainingPot}. Ticket records preserved for manual recovery.`); await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { - message: `The lottery draw was cancelled — not enough participants. Refunds were partially processed (${entries.length - refundFailures}/${entries.length} succeeded). Please contact an admin if you did not receive your refund.`, + message: `The jackpot draw was cancelled — not enough participants. Refunds were partially processed (${entries.length - refundFailures}/${entries.length} succeeded). Please contact an admin if you did not receive your refund.`, opts: {}, }); } @@ -110,14 +110,14 @@ async function main() { if (!winner) { // Fallback: pick the last entry (shouldn't happen with correct logic) winner = entries[entries.length - 1]; - console.error(`draw-lottery: winner selection fallback triggered. randomTarget=${randomTarget}, totalTickets=${totalTickets}`); + console.error(`jackpot-cron: winner selection fallback triggered. randomTarget=${randomTarget}, totalTickets=${totalTickets}`); } const prize = Math.max(1, Math.floor(totalPot * (1 - config.profitMargin))); const newDrawNumber = drawNumber + 1; console.log( - `draw-lottery: winner=${winner.playerId}, tickets=${winner.tickets}/${totalTickets}, prize=${prize}, drawNumber=${newDrawNumber}`, + `jackpot-cron: winner=${winner.playerId}, tickets=${winner.tickets}/${totalTickets}, prize=${prize}, drawNumber=${newDrawNumber}`, ); // Award the prize; only reset state if award succeeds @@ -128,13 +128,13 @@ async function main() { }); prizeAwarded = true; } catch (prizeErr) { - console.error(`draw-lottery: CRITICAL — failed to award prize to winner ${winner.playerId} (prize=${prize}). State NOT reset to allow manual recovery. Error: ${prizeErr}`); + console.error(`jackpot-cron: CRITICAL — failed to award prize to winner ${winner.playerId} (prize=${prize}). State NOT reset to allow manual recovery. Error: ${prizeErr}`); } if (!prizeAwarded) { // Do not reset state — preserve for manual admin recovery await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { - message: `LOTTERY ERROR: The draw for Draw #${newDrawNumber} could not be completed — prize award failed. Please contact an admin for manual resolution.`, + message: `JACKPOT ERROR: The draw for Draw #${newDrawNumber} could not be completed — prize award failed. Please contact an admin for manual resolution.`, opts: {}, }); return; @@ -144,22 +144,22 @@ async function main() { try { await setDrawNumber(gameServerId, moduleId, newDrawNumber); } catch (err) { - console.error(`draw-lottery: failed to setDrawNumber. Error: ${err}`); + console.error(`jackpot-cron: failed to setDrawNumber. Error: ${err}`); } try { await setPot(gameServerId, moduleId, 0); } catch (err) { - console.error(`draw-lottery: failed to setPot to 0. Error: ${err}`); + console.error(`jackpot-cron: failed to setPot to 0. Error: ${err}`); } try { await setRollover(gameServerId, moduleId, 0); } catch (err) { - console.error(`draw-lottery: failed to setRollover to 0. Error: ${err}`); + console.error(`jackpot-cron: failed to setRollover to 0. Error: ${err}`); } try { await deleteAllTickets(gameServerId, moduleId); } catch (err) { - console.error(`draw-lottery: failed to deleteAllTickets. Error: ${err}`); + console.error(`jackpot-cron: failed to deleteAllTickets. Error: ${err}`); } // Look up winner's player name for the broadcast @@ -170,12 +170,12 @@ async function main() { winnerName = playerResult.data.data.name; } } catch (lookupErr) { - console.error(`draw-lottery: failed to look up winner name for ${winner.playerId}. Using "Unknown Player". Error: ${lookupErr}`); + console.error(`jackpot-cron: failed to look up winner name for ${winner.playerId}. Using "Unknown Player". Error: ${lookupErr}`); } // Lead with player name, then prize details await takaro.gameserver.gameServerControllerSendMessage(gameServerId, { - message: `*** LOTTERY DRAW *** Congratulations to ${winnerName} for winning ${prize} currency! (Draw #${newDrawNumber} — held ${winner.tickets}/${totalTickets} tickets) See you in the next draw!`, + message: `*** JACKPOT DRAW *** Congratulations to ${winnerName} for winning ${prize} currency! (Draw #${newDrawNumber} — held ${winner.tickets}/${totalTickets} tickets) See you in the next draw!`, opts: {}, }); } diff --git a/modules/lottery/src/functions/lottery-helpers.js b/modules/jackpot/src/functions/jackpot-helpers.js similarity index 77% rename from modules/lottery/src/functions/lottery-helpers.js rename to modules/jackpot/src/functions/jackpot-helpers.js index 874f31f..13f07c0 100644 --- a/modules/lottery/src/functions/lottery-helpers.js +++ b/modules/jackpot/src/functions/jackpot-helpers.js @@ -1,9 +1,9 @@ import { takaro } from '@takaro/helpers'; -export const LOTTERY_POT_KEY = 'lottery_pot'; -export const LOTTERY_DRAW_NUMBER_KEY = 'lottery_draw_number'; -export const LOTTERY_ROLLOVER_KEY = 'lottery_rollover'; -export const LOTTERY_TICKETS_KEY = 'lottery_tickets'; +export const JACKPOT_POT_KEY = 'jackpot_pot'; +export const JACKPOT_DRAW_NUMBER_KEY = 'jackpot_draw_number'; +export const JACKPOT_ROLLOVER_KEY = 'jackpot_rollover'; +export const JACKPOT_TICKETS_KEY = 'jackpot_tickets'; /** * Generic variable read helper. Returns the variable record or null if not found. @@ -49,77 +49,77 @@ export async function deleteVariable(gameServerId, moduleId, key, playerId) { // ---- Global state helpers ---- export async function getPot(gameServerId, moduleId) { - const variable = await findVariable(gameServerId, moduleId, LOTTERY_POT_KEY); + const variable = await findVariable(gameServerId, moduleId, JACKPOT_POT_KEY); if (!variable) return 0; try { const val = Math.floor(JSON.parse(variable.value)); return isNaN(val) ? 0 : val; } catch (err) { - console.error(`lottery-helpers: getPot failed to parse stored value, returning 0. Error: ${err}`); + console.error(`jackpot-helpers: getPot failed to parse stored value, returning 0. Error: ${err}`); return 0; } } export async function setPot(gameServerId, moduleId, amount) { if (typeof amount !== 'number' || isNaN(amount)) amount = 0; - await writeVariable(gameServerId, moduleId, LOTTERY_POT_KEY, Math.floor(amount)); + await writeVariable(gameServerId, moduleId, JACKPOT_POT_KEY, Math.floor(amount)); } export async function getRollover(gameServerId, moduleId) { - const variable = await findVariable(gameServerId, moduleId, LOTTERY_ROLLOVER_KEY); + const variable = await findVariable(gameServerId, moduleId, JACKPOT_ROLLOVER_KEY); if (!variable) return 0; try { const val = Math.floor(JSON.parse(variable.value)); return isNaN(val) ? 0 : val; } catch (err) { - console.error(`lottery-helpers: getRollover failed to parse stored value, returning 0. Error: ${err}`); + console.error(`jackpot-helpers: getRollover failed to parse stored value, returning 0. Error: ${err}`); return 0; } } export async function setRollover(gameServerId, moduleId, amount) { if (typeof amount !== 'number' || isNaN(amount)) amount = 0; - await writeVariable(gameServerId, moduleId, LOTTERY_ROLLOVER_KEY, Math.floor(amount)); + await writeVariable(gameServerId, moduleId, JACKPOT_ROLLOVER_KEY, Math.floor(amount)); } export async function getDrawNumber(gameServerId, moduleId) { - const variable = await findVariable(gameServerId, moduleId, LOTTERY_DRAW_NUMBER_KEY); + const variable = await findVariable(gameServerId, moduleId, JACKPOT_DRAW_NUMBER_KEY); if (!variable) return 0; try { const val = Math.floor(JSON.parse(variable.value)); return isNaN(val) ? 0 : val; } catch (err) { - console.error(`lottery-helpers: getDrawNumber failed to parse stored value, returning 0. Error: ${err}`); + console.error(`jackpot-helpers: getDrawNumber failed to parse stored value, returning 0. Error: ${err}`); return 0; } } export async function setDrawNumber(gameServerId, moduleId, num) { if (typeof num !== 'number' || isNaN(num)) num = 0; - await writeVariable(gameServerId, moduleId, LOTTERY_DRAW_NUMBER_KEY, Math.floor(num)); + await writeVariable(gameServerId, moduleId, JACKPOT_DRAW_NUMBER_KEY, Math.floor(num)); } // ---- Per-player helpers ---- export async function getPlayerTickets(gameServerId, moduleId, playerId) { - const variable = await findVariable(gameServerId, moduleId, LOTTERY_TICKETS_KEY, playerId); + const variable = await findVariable(gameServerId, moduleId, JACKPOT_TICKETS_KEY, playerId); if (!variable) return 0; try { const val = Math.floor(JSON.parse(variable.value)); return isNaN(val) ? 0 : val; } catch (err) { - console.error(`lottery-helpers: getPlayerTickets failed to parse stored value, returning 0. Error: ${err}`); + console.error(`jackpot-helpers: getPlayerTickets failed to parse stored value, returning 0. Error: ${err}`); return 0; } } export async function setPlayerTickets(gameServerId, moduleId, playerId, tickets) { if (typeof tickets !== 'number' || isNaN(tickets)) tickets = 0; - await writeVariable(gameServerId, moduleId, LOTTERY_TICKETS_KEY, Math.floor(tickets), playerId); + await writeVariable(gameServerId, moduleId, JACKPOT_TICKETS_KEY, Math.floor(tickets), playerId); } /** - * Paginated search for all lottery_tickets variables. + * Paginated search for all jackpot_tickets variables. * Returns [{playerId, tickets, variableId}] */ export async function getAllTicketEntries(gameServerId, moduleId) { @@ -130,7 +130,7 @@ export async function getAllTicketEntries(gameServerId, moduleId) { if (page > 100) break; const res = await takaro.variable.variableControllerSearch({ filters: { - key: [LOTTERY_TICKETS_KEY], + key: [JACKPOT_TICKETS_KEY], gameServerId: [gameServerId], moduleId: [moduleId], }, @@ -145,7 +145,7 @@ export async function getAllTicketEntries(gameServerId, moduleId) { entries.push({ playerId: v.playerId, tickets, variableId: v.id }); } } catch (err) { - console.error(`lottery-helpers: failed to parse ticket variable ${v.id}, skipping. Error: ${err}`); + console.error(`jackpot-helpers: failed to parse ticket variable ${v.id}, skipping. Error: ${err}`); } } if (entries.length >= res.data.meta.total || batch.length < limit) break; @@ -155,7 +155,7 @@ export async function getAllTicketEntries(gameServerId, moduleId) { } /** - * Paginated delete of all lottery_tickets variables. + * Paginated delete of all jackpot_tickets variables. */ export async function deleteAllTickets(gameServerId, moduleId) { let deleted = 0; @@ -165,7 +165,7 @@ export async function deleteAllTickets(gameServerId, moduleId) { iterations++; const res = await takaro.variable.variableControllerSearch({ filters: { - key: [LOTTERY_TICKETS_KEY], + key: [JACKPOT_TICKETS_KEY], gameServerId: [gameServerId], moduleId: [moduleId], }, @@ -178,9 +178,9 @@ export async function deleteAllTickets(gameServerId, moduleId) { deleted += results.filter(r => r.status === 'fulfilled').length; const rejected = results.filter(r => r.status === 'rejected'); if (rejected.length > 0) { - rejected.forEach(r => console.error(`lottery-helpers: deleteAllTickets failed to delete a ticket record. Reason: ${r.reason}`)); + rejected.forEach(r => console.error(`jackpot-helpers: deleteAllTickets failed to delete a ticket record. Reason: ${r.reason}`)); } if (batch.length < 100) break; } - console.log(`lottery-helpers: deleteAllTickets deleted ${deleted} ticket records`); + console.log(`jackpot-helpers: deleteAllTickets deleted ${deleted} ticket records`); } diff --git a/modules/lottery/test/buy-ticket.test.ts b/modules/jackpot/test/buy-ticket.test.ts similarity index 95% rename from modules/lottery/test/buy-ticket.test.ts rename to modules/jackpot/test/buy-ticket.test.ts index bf11021..b9bf5a8 100644 --- a/modules/lottery/test/buy-ticket.test.ts +++ b/modules/jackpot/test/buy-ticket.test.ts @@ -22,11 +22,11 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); -// NOTE: Tests in this suite run sequentially and share lottery state across all cases. -// player[0] has LOTTERY_BUY; player[1] does NOT. Ticket price=10, maxTicketsPerPlayer=5. +// NOTE: Tests in this suite run sequentially and share jackpot state across all cases. +// player[0] has JACKPOT_BUY; player[1] does NOT. Ticket price=10, maxTicketsPerPlayer=5. // Command cost auto-deducts 1x ticketPrice; handler deducts (amount-1)*ticketPrice additional. -describe('lottery: /buyticket command', () => { +describe('jackpot: /buyticket command', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -72,12 +72,12 @@ describe('lottery: /buyticket command', () => { prefix = await getCommandPrefix(client, ctx.gameServer.id); - // Assign LOTTERY_BUY permission to player[0] only + // Assign JACKPOT_BUY permission to player[0] only buyRoleId = await assignPermissions( client, ctx.players[0].playerId, ctx.gameServer.id, - ['LOTTERY_BUY'], + ['JACKPOT_BUY'], ); // Give player[0] some starting currency (500) @@ -140,7 +140,7 @@ describe('lottery: /buyticket command', () => { const logMessages = (meta?.result?.logs ?? []).map((l) => l.msg); assert.ok( - logMessages.some((msg) => msg.includes('Lottery:') && msg.includes('buying 1 tickets')), + logMessages.some((msg) => msg.includes('jackpot-buy:') && msg.includes('buying 1 tickets')), `Expected log to contain "buying 1 tickets", got: ${JSON.stringify(logMessages)}`, ); @@ -188,7 +188,7 @@ describe('lottery: /buyticket command', () => { const logMessages = (meta?.result?.logs ?? []).map((l) => l.msg); assert.ok( - logMessages.some((msg) => msg.includes('Lottery:') && msg.includes('buying 2 tickets')), + logMessages.some((msg) => msg.includes('jackpot-buy:') && msg.includes('buying 2 tickets')), `Expected log to show buying 2 tickets, got: ${JSON.stringify(logMessages)}`, ); // Verify cumulative ticket count is now 3 @@ -209,8 +209,8 @@ describe('lottery: /buyticket command', () => { ); }); - it('should deny purchase without LOTTERY_BUY permission', async () => { - // player[1] has no LOTTERY_BUY permission + it('should deny purchase without JACKPOT_BUY permission', async () => { + // player[1] has no JACKPOT_BUY permission const player = ctx.players[1]!; const before = new Date(); diff --git a/modules/lottery/test/draw-lottery-rollover.test.ts b/modules/jackpot/test/draw-jackpot-rollover.test.ts similarity index 98% rename from modules/lottery/test/draw-lottery-rollover.test.ts rename to modules/jackpot/test/draw-jackpot-rollover.test.ts index eaddd43..6ffa778 100644 --- a/modules/lottery/test/draw-lottery-rollover.test.ts +++ b/modules/jackpot/test/draw-jackpot-rollover.test.ts @@ -22,7 +22,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); -describe('lottery: draw-lottery rolloverOnCancel', () => { +describe('jackpot: draw-jackpot rolloverOnCancel', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -68,7 +68,7 @@ describe('lottery: draw-lottery rolloverOnCancel', () => { }); const cronjob = mod.latestVersion.cronJobs[0]; - if (!cronjob) throw new Error('Expected at least one cronjob in lottery module'); + if (!cronjob) throw new Error('Expected at least one cronjob in jackpot module'); cronjobId = cronjob.id; prefix = await getCommandPrefix(client, ctx.gameServer.id); @@ -77,7 +77,7 @@ describe('lottery: draw-lottery rolloverOnCancel', () => { client, ctx.players[0].playerId, ctx.gameServer.id, - ['LOTTERY_BUY'], + ['JACKPOT_BUY'], ); await client.playerOnGameserver.playerOnGameServerControllerAddCurrency( diff --git a/modules/lottery/test/draw-lottery.test.ts b/modules/jackpot/test/draw-jackpot.test.ts similarity index 97% rename from modules/lottery/test/draw-lottery.test.ts rename to modules/jackpot/test/draw-jackpot.test.ts index 1d6472d..8058a4c 100644 --- a/modules/lottery/test/draw-lottery.test.ts +++ b/modules/jackpot/test/draw-jackpot.test.ts @@ -22,7 +22,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); -describe('lottery: draw-lottery cronjob', () => { +describe('jackpot: draw-jackpot cronjob', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -69,23 +69,23 @@ describe('lottery: draw-lottery cronjob', () => { }); const cronjob = mod.latestVersion.cronJobs[0]; - if (!cronjob) throw new Error('Expected at least one cronjob in lottery module'); + if (!cronjob) throw new Error('Expected at least one cronjob in jackpot module'); cronjobId = cronjob.id; prefix = await getCommandPrefix(client, ctx.gameServer.id); - // Assign LOTTERY_BUY permission to player[0] and player[1] + // Assign JACKPOT_BUY permission to player[0] and player[1] buyRoleId = await assignPermissions( client, ctx.players[0].playerId, ctx.gameServer.id, - ['LOTTERY_BUY'], + ['JACKPOT_BUY'], ); buy2RoleId = await assignPermissions( client, ctx.players[1].playerId, ctx.gameServer.id, - ['LOTTERY_BUY'], + ['JACKPOT_BUY'], ); // Give both players currency @@ -227,7 +227,7 @@ describe('lottery: draw-lottery cronjob', () => { assert.equal(success, true, `Expected draw to succeed, logs: ${JSON.stringify(logs)}`); assert.ok( - logs.some((msg) => msg.includes('draw-lottery: winner=')), + logs.some((msg) => msg.includes('jackpot-cron: winner=')), `Expected winner selection log, got: ${JSON.stringify(logs)}`, ); assert.ok( diff --git a/modules/lottery/test/lottery-info.test.ts b/modules/jackpot/test/jackpot-info.test.ts similarity index 91% rename from modules/lottery/test/lottery-info.test.ts rename to modules/jackpot/test/jackpot-info.test.ts index ba2bd5e..0051e85 100644 --- a/modules/lottery/test/lottery-info.test.ts +++ b/modules/jackpot/test/jackpot-info.test.ts @@ -22,7 +22,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); -describe('lottery: /lotteryinfo command', () => { +describe('jackpot: /jackpotinfo command', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -59,12 +59,12 @@ describe('lottery: /lotteryinfo command', () => { prefix = await getCommandPrefix(client, ctx.gameServer.id); - // Assign LOTTERY_BUY to player[0] for the ticket purchase test + // Assign JACKPOT_BUY to player[0] for the ticket purchase test buyRoleId = await assignPermissions( client, ctx.players[0].playerId, ctx.gameServer.id, - ['LOTTERY_BUY'], + ['JACKPOT_BUY'], ); // Give player[0] currency for purchases @@ -95,7 +95,7 @@ describe('lottery: /lotteryinfo command', () => { const before = new Date(); await client.command.commandControllerTrigger(ctx.gameServer.id, { - msg: `${prefix}lotteryinfo`, + msg: `${prefix}jackpotinfo`, playerId: player.playerId, }); @@ -108,11 +108,11 @@ describe('lottery: /lotteryinfo command', () => { assert.ok(event, 'Expected a command-executed event'); const meta = event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }; - assert.equal(meta?.result?.success, true, 'Expected lotteryinfo to succeed'); + assert.equal(meta?.result?.success, true, 'Expected jackpotinfo to succeed'); const logMessages = (meta?.result?.logs ?? []).map((l) => l.msg); assert.ok( - logMessages.some((msg) => msg.includes('lottery-info:') && msg.includes('participants=0')), + logMessages.some((msg) => msg.includes('jackpot-info:') && msg.includes('participants=0')), `Expected log to show participants=0, got: ${JSON.stringify(logMessages)}`, ); }); @@ -134,10 +134,10 @@ describe('lottery: /lotteryinfo command', () => { timeout: 30000, }); - // Now check lotteryinfo + // Now check jackpotinfo const infoBefore = new Date(); await client.command.commandControllerTrigger(ctx.gameServer.id, { - msg: `${prefix}lotteryinfo`, + msg: `${prefix}jackpotinfo`, playerId: player.playerId, }); @@ -150,12 +150,12 @@ describe('lottery: /lotteryinfo command', () => { assert.ok(event, 'Expected a command-executed event'); const meta = event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }; - assert.equal(meta?.result?.success, true, 'Expected lotteryinfo to succeed after purchases'); + assert.equal(meta?.result?.success, true, 'Expected jackpotinfo to succeed after purchases'); const logMessages = (meta?.result?.logs ?? []).map((l) => l.msg); // After buying 3 tickets at price 10 each, pot should be 30 assert.ok( - logMessages.some((msg) => msg.includes('lottery-info:') && msg.includes('participants=1')), + logMessages.some((msg) => msg.includes('jackpot-info:') && msg.includes('participants=1')), `Expected log to show participants=1, got: ${JSON.stringify(logMessages)}`, ); assert.ok( diff --git a/modules/lottery/test/next-draw.test.ts b/modules/jackpot/test/next-draw.test.ts similarity index 98% rename from modules/lottery/test/next-draw.test.ts rename to modules/jackpot/test/next-draw.test.ts index e7dd02b..f7f5501 100644 --- a/modules/lottery/test/next-draw.test.ts +++ b/modules/jackpot/test/next-draw.test.ts @@ -20,7 +20,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); -describe('lottery: /nextdraw command', () => { +describe('jackpot: /nextdraw command', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; diff --git a/modules/lottery/test/view-tickets.test.ts b/modules/jackpot/test/view-tickets.test.ts similarity index 93% rename from modules/lottery/test/view-tickets.test.ts rename to modules/jackpot/test/view-tickets.test.ts index 517427d..b5d04ca 100644 --- a/modules/lottery/test/view-tickets.test.ts +++ b/modules/jackpot/test/view-tickets.test.ts @@ -23,12 +23,12 @@ const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); // Tests run sequentially with shared state: -// player[0] has LOTTERY_BUY and LOTTERY_VIEW_TICKETS; player[1] has neither. -// 1. Permission denied for player[1] (no LOTTERY_VIEW_TICKETS) +// player[0] has JACKPOT_BUY and JACKPOT_VIEW_TICKETS; player[1] has neither. +// 1. Permission denied for player[1] (no JACKPOT_VIEW_TICKETS) // 2. Zero tickets for player[0] before any purchase // 3. Correct count (2) after purchase of 2 tickets -describe('lottery: /viewtickets command', () => { +describe('jackpot: /viewtickets command', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -65,12 +65,12 @@ describe('lottery: /viewtickets command', () => { prefix = await getCommandPrefix(client, ctx.gameServer.id); - // Assign both LOTTERY_BUY and LOTTERY_VIEW_TICKETS to player[0] + // Assign both JACKPOT_BUY and JACKPOT_VIEW_TICKETS to player[0] permRoleId = await assignPermissions( client, ctx.players[0].playerId, ctx.gameServer.id, - ['LOTTERY_BUY', 'LOTTERY_VIEW_TICKETS'], + ['JACKPOT_BUY', 'JACKPOT_VIEW_TICKETS'], ); // Give player[0] currency for purchases @@ -96,8 +96,8 @@ describe('lottery: /viewtickets command', () => { await stopMockServer(ctx.server, client, ctx.gameServer.id); }); - it('should deny viewtickets to player without LOTTERY_VIEW_TICKETS permission', async () => { - // player[1] has no LOTTERY_VIEW_TICKETS permission + it('should deny viewtickets to player without JACKPOT_VIEW_TICKETS permission', async () => { + // player[1] has no JACKPOT_VIEW_TICKETS permission const player = ctx.players[1]!; const before = new Date(); diff --git a/modules/kill-tracker/module.json b/modules/kill-tracker/module.json index 806573e..9b5dfe2 100644 --- a/modules/kill-tracker/module.json +++ b/modules/kill-tracker/module.json @@ -1,7 +1,7 @@ { - "name": "test-kill-tracker", - "description": "Kill/Death Tracker — tracks player kills, deaths, and points with streaks, leaderboards, and seasonal resets.", - "version": "latest", + "name": "kill-tracker", + "description": "Kill/Death Tracker \u2014 tracks player kills, deaths, and points with streaks, leaderboards, and seasonal resets.", + "version": "1.0.0", "supportedGames": [ "all" ], diff --git a/modules/mini-games/module.json b/modules/mini-games/module.json index 0203b21..4f1ba3c 100644 --- a/modules/mini-games/module.json +++ b/modules/mini-games/module.json @@ -1,9 +1,10 @@ { - "name": "test-mini-games", - "description": "Unified mini-games module with daily puzzles (Wordle, Hangman, Hot/Cold) and live chat rounds (Trivia, Scramble, Math race, Reaction race). Skill-based, point-scoring, optional currency rewards, boost tiers, cross-game leaderboards.", - "version": "latest", - "supportedGames": ["all"], - + "name": "mini-games", + "description": "Player-facing chat games — seven games total: daily puzzles (Wordle, Hangman, Hot/Cold) and live rounds (Trivia, Scramble, Math Race, Reaction Race) with point scoring, leaderboards, and optional currency rewards.", + "version": "1.0.0", + "supportedGames": [ + "all" + ], "config": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", @@ -71,36 +72,73 @@ }, "triviaQuestionSource": { "type": "string", - "enum": ["api", "custom"], + "enum": [ + "api", + "custom" + ], "default": "api", "description": "Trivia source. 'api' fetches from Open Trivia DB; falls back to custom bank on failure." }, "triviaApiCategory": { "type": "array", - "default": ["any"], - "items": { "type": "string" }, + "default": [ + "any" + ], + "items": { + "type": "string" + }, "description": "OpenTDB category keys. ['any'] = no filter. Multiple = random pick per round." }, "triviaApiDifficulty": { "type": "string", - "enum": ["any", "easy", "medium", "hard"], + "enum": [ + "any", + "easy", + "medium", + "hard" + ], "default": "any" }, "triviaApiType": { "type": "string", - "enum": ["any", "multiple", "boolean"], + "enum": [ + "any", + "multiple", + "boolean" + ], "default": "any" }, "games": { "type": "object", "properties": { - "wordle": { "type": "boolean", "default": true }, - "hangman": { "type": "boolean", "default": true }, - "hotcold": { "type": "boolean", "default": true }, - "trivia": { "type": "boolean", "default": true }, - "scramble": { "type": "boolean", "default": true }, - "mathrace": { "type": "boolean", "default": true }, - "reactionrace": { "type": "boolean", "default": true } + "wordle": { + "type": "boolean", + "default": true + }, + "hangman": { + "type": "boolean", + "default": true + }, + "hotcold": { + "type": "boolean", + "default": true + }, + "trivia": { + "type": "boolean", + "default": true + }, + "scramble": { + "type": "boolean", + "default": true + }, + "mathrace": { + "type": "boolean", + "default": true + }, + "reactionrace": { + "type": "boolean", + "default": true + } }, "default": { "wordle": true, @@ -116,9 +154,7 @@ "required": [], "additionalProperties": false }, - "uiSchema": {}, - "permissions": [ { "permission": "MINIGAMES_PLAY", @@ -139,12 +175,11 @@ "canHaveCount": false } ], - "commands": { "minigames": { "trigger": "minigames", "description": "Help and overview for mini-games.", - "helpText": "Usage: /minigames [game] — Show help for all games or a specific game.", + "helpText": "Usage: /minigames [game] \u2014 Show help for all games or a specific game.", "function": "src/commands/minigames/index.js", "arguments": [ { @@ -159,7 +194,7 @@ "wordle": { "trigger": "wordle", "description": "Daily 5-letter word puzzle.", - "helpText": "Usage: /wordle [guess] — No arg shows today's attempts; with 5-letter guess submits a guess.", + "helpText": "Usage: /wordle [guess] \u2014 No arg shows today's attempts; with 5-letter guess submits a guess.", "function": "src/commands/wordle/index.js", "arguments": [ { @@ -174,7 +209,7 @@ "hangman": { "trigger": "hangman", "description": "Daily hangman puzzle.", - "helpText": "Usage: /hangman [letter|word] — No arg shows board; letter guesses a letter; full word attempts solve.", + "helpText": "Usage: /hangman [letter|word] \u2014 No arg shows board; letter guesses a letter; full word attempts solve.", "function": "src/commands/hangman/index.js", "arguments": [ { @@ -189,7 +224,7 @@ "hotcold": { "trigger": "hotcold", "description": "Daily hot/cold number guessing game.", - "helpText": "Usage: /hotcold [number] — No arg shows attempts; number 1-1000 submits a guess.", + "helpText": "Usage: /hotcold [number] \u2014 No arg shows attempts; number 1-1000 submits a guess.", "function": "src/commands/hotcold/index.js", "arguments": [ { @@ -204,7 +239,7 @@ "answer": { "trigger": "answer", "description": "Answer the currently active live round.", - "helpText": "Usage: /answer — Submit your answer to the active live round.", + "helpText": "Usage: /answer \u2014 Submit your answer to the active live round.", "function": "src/commands/answer/index.js", "arguments": [ { @@ -218,7 +253,7 @@ "minigamestats": { "trigger": "minigamestats", "description": "View mini-games stats.", - "helpText": "Usage: /minigamestats [player] — Show lifetime and current-day stats.", + "helpText": "Usage: /minigamestats [player] \u2014 Show lifetime and current-day stats.", "function": "src/commands/minigamestats/index.js", "arguments": [ { @@ -233,7 +268,7 @@ "minigamestop": { "trigger": "minigamestop", "description": "View mini-games leaderboards.", - "helpText": "Usage: /minigamestop — Show leaderboard.", + "helpText": "Usage: /minigamestop \u2014 Show leaderboard.", "function": "src/commands/minigamestop/index.js", "arguments": [ { @@ -247,14 +282,14 @@ "puzzle": { "trigger": "puzzle", "description": "Status of today's async puzzles.", - "helpText": "Usage: /puzzle — Shows which daily puzzles you've played and time until rollover.", + "helpText": "Usage: /puzzle \u2014 Shows which daily puzzles you've played and time until rollover.", "function": "src/commands/puzzle/index.js", "arguments": [] }, "minigamesban": { "trigger": "minigamesban", "description": "Ban a player from mini-games.", - "helpText": "Usage: /minigamesban [hours] — Ban permanently (no hours) or temporarily.", + "helpText": "Usage: /minigamesban [hours] \u2014 Ban permanently (no hours) or temporarily.", "function": "src/commands/minigamesban/index.js", "arguments": [ { @@ -275,7 +310,7 @@ "minigamesunban": { "trigger": "minigamesunban", "description": "Unban a player from mini-games.", - "helpText": "Usage: /minigamesunban — Remove a mini-games ban.", + "helpText": "Usage: /minigamesunban \u2014 Remove a mini-games ban.", "function": "src/commands/minigamesunban/index.js", "arguments": [ { @@ -289,7 +324,7 @@ "minigamesresetstats": { "trigger": "minigamesresetstats", "description": "Reset a player's mini-games stats.", - "helpText": "Usage: /minigamesresetstats — Wipe all stats for a player.", + "helpText": "Usage: /minigamesresetstats \u2014 Wipe all stats for a player.", "function": "src/commands/minigamesresetstats/index.js", "arguments": [ { @@ -303,14 +338,14 @@ "minigamesskiproundnow": { "trigger": "minigamesskiproundnow", "description": "Cancel the current live round.", - "helpText": "Usage: /minigamesskiproundnow — Cancel the active live round with no winner.", + "helpText": "Usage: /minigamesskiproundnow \u2014 Cancel the active live round with no winner.", "function": "src/commands/minigamesskiproundnow/index.js", "arguments": [] }, "minigamesfirenow": { "trigger": "minigamesfirenow", "description": "Fire a live round immediately.", - "helpText": "Usage: /minigamesfirenow [game] — Start a live round now, optionally picking the game.", + "helpText": "Usage: /minigamesfirenow [game] \u2014 Start a live round now, optionally picking the game.", "function": "src/commands/minigamesfirenow/index.js", "arguments": [ { @@ -324,12 +359,11 @@ "minigamesreport": { "trigger": "minigamesreport", "description": "PM admin with mini-games report.", - "helpText": "Usage: /minigamesreport — Send a lifetime stats report (total rounds, points, top 5, per-game breakdown).", + "helpText": "Usage: /minigamesreport \u2014 Send a lifetime stats report (total rounds, points, top 5, per-game breakdown).", "function": "src/commands/minigamesreport/index.js", "arguments": [] } }, - "hooks": { "onChatMessage": { "eventType": "chat-message", @@ -342,7 +376,6 @@ "function": "src/hooks/on-player-disconnect/index.js" } }, - "cronJobs": { "rolloverDailyPuzzles": { "temporalValue": "0 0 * * *", @@ -375,7 +408,6 @@ "function": "src/cronjobs/expire-bans/index.js" } }, - "functions": { "mini-games-helpers": { "function": "src/functions/mini-games-helpers.js" diff --git a/modules/referral-program/module.json b/modules/referral-program/module.json index ce9080e..d6094e1 100644 --- a/modules/referral-program/module.json +++ b/modules/referral-program/module.json @@ -1,7 +1,7 @@ { - "name": "test-referral-program", + "name": "referral-program", "description": "Players invite others via a referral code; referrers earn currency or items when their referees hit a playtime threshold, with daily/lifetime caps and VIP tiering.", - "version": "latest", + "version": "1.0.0", "supportedGames": [ "all" ], @@ -113,14 +113,14 @@ "refcode": { "trigger": "refcode", "description": "Get your referral code (generates one if you don't have one yet)", - "helpText": "Usage: /refcode — Displays your unique referral code. Share it with new players to earn rewards when they join.", + "helpText": "Usage: /refcode \u2014 Displays your unique referral code. Share it with new players to earn rewards when they join.", "function": "src/commands/refcode/index.js", "arguments": [] }, "referral": { "trigger": "referral", "description": "Claim a referral using a referral code", - "helpText": "Usage: /referral — Enter a referral code from the player who invited you. You must use this within the referral window of your first join (see server config).", + "helpText": "Usage: /referral \u2014 Enter a referral code from the player who invited you. You must use this within the referral window of your first join (see server config).", "function": "src/commands/referral/index.js", "arguments": [ { @@ -134,21 +134,21 @@ "refstats": { "trigger": "refstats", "description": "View your referral statistics", - "helpText": "Usage: /refstats — View your referral stats including total referrals, earnings, and who referred you.", + "helpText": "Usage: /refstats \u2014 View your referral stats including total referrals, earnings, and who referred you.", "function": "src/commands/refstats/index.js", "arguments": [] }, "reftop": { "trigger": "reftop", "description": "View the referral leaderboard (top 10 referrers by paid referrals)", - "helpText": "Usage: /reftop — Shows the top 10 players by number of successful referrals.", + "helpText": "Usage: /reftop \u2014 Shows the top 10 players by number of successful referrals.", "function": "src/commands/reftop/index.js", "arguments": [] }, "reflink": { "trigger": "reflink", "description": "Admin: Force-link a referee to a referrer and pay both rewards", - "helpText": "Usage: /reflink — Admin only. Provide the full exact display name of each player (case-sensitive). Force-creates a paid link and pays both rewards.", + "helpText": "Usage: /reflink \u2014 Admin only. Provide the full exact display name of each player (case-sensitive). Force-creates a paid link and pays both rewards.", "function": "src/commands/reflink/index.js", "arguments": [ { @@ -168,7 +168,7 @@ "refunlink": { "trigger": "refunlink", "description": "Admin: Remove a referral link for a referee", - "helpText": "Usage: /refunlink — Admin only. Provide the full exact display name of the referee player (case-sensitive). Removes their referral link.", + "helpText": "Usage: /refunlink \u2014 Admin only. Provide the full exact display name of the referee player (case-sensitive). Removes their referral link.", "function": "src/commands/refunlink/index.js", "arguments": [ { diff --git a/modules/server-messages/module.json b/modules/server-messages/module.json index 33c45ce..4bbd248 100644 --- a/modules/server-messages/module.json +++ b/modules/server-messages/module.json @@ -1,7 +1,7 @@ { - "name": "test-server-messages", + "name": "server-messages", "description": "Broadcasts scheduled announcements to all connected players. Supports sequential rotation and weighted shuffle-bag rotation.", - "version": "latest", + "version": "1.0.0", "supportedGames": [ "all" ], @@ -27,14 +27,19 @@ "description": "How many times this message appears in the shuffle bag (only used when order=random)." } }, - "required": ["text"], + "required": [ + "text" + ], "additionalProperties": false }, "default": [] }, "order": { "type": "string", - "enum": ["sequential", "random"], + "enum": [ + "sequential", + "random" + ], "default": "sequential", "description": "Rotation mode. sequential sends messages in order; random uses weighted shuffle-bag rotation." } diff --git a/modules/utils/module.json b/modules/server-toolkit/module.json similarity index 94% rename from modules/utils/module.json rename to modules/server-toolkit/module.json index 814db61..1264de6 100644 --- a/modules/utils/module.json +++ b/modules/server-toolkit/module.json @@ -1,7 +1,7 @@ { - "name": "test-utils", + "name": "server-toolkit", "description": "Cross-game utility commands for server info, rules, moderation, and simple economy administration.", - "version": "latest", + "version": "1.0.0", "supportedGames": [ "all" ], @@ -79,19 +79,19 @@ { "canHaveCount": false, "description": "Allows a player to kick other players", - "permission": "UTILS_KICK", + "permission": "TOOLKIT_KICK", "friendlyName": "Kick Players" }, { "canHaveCount": false, "description": "Allows a player to ban other players", - "permission": "UTILS_BAN", + "permission": "TOOLKIT_BAN", "friendlyName": "Ban Players" }, { "canHaveCount": false, "description": "Allows a player to grant currency to players", - "permission": "UTILS_GIVE_CURRENCY", + "permission": "TOOLKIT_GIVE_CURRENCY", "friendlyName": "Give Currency" } ], @@ -199,11 +199,11 @@ } }, "functions": { - "utils-helpers": { - "function": "src/functions/utils-helpers.js" + "server-toolkit-helpers": { + "function": "src/functions/server-toolkit-helpers.js" }, - "utils-pure": { - "function": "src/functions/utils-pure.js" + "server-toolkit-pure": { + "function": "src/functions/server-toolkit-pure.js" } } } diff --git a/modules/utils/src/commands/ban/index.js b/modules/server-toolkit/src/commands/ban/index.js similarity index 83% rename from modules/utils/src/commands/ban/index.js rename to modules/server-toolkit/src/commands/ban/index.js index f17c298..87575a8 100644 --- a/modules/utils/src/commands/ban/index.js +++ b/modules/server-toolkit/src/commands/ban/index.js @@ -4,16 +4,16 @@ import { normalizeReason, parseBanDurationToken, renderTemplate, -} from './utils-pure.js'; +} from './server-toolkit-pure.js'; import { getPlayerName, safeBroadcast, -} from './utils-helpers.js'; +} from './server-toolkit-helpers.js'; async function main() { const { pog, player, gameServerId, arguments: args, module: mod } = data; - if (!checkPermission(pog, 'UTILS_BAN')) { + if (!checkPermission(pog, 'TOOLKIT_BAN')) { throw new TakaroUserError('You do not have permission to use this command.'); } @@ -44,26 +44,26 @@ async function main() { payload.expiresAt = parsedDuration.expiresAt; } - console.log(`utils:ban payload=${JSON.stringify(payload)}`); + console.log(`toolkit:ban payload=${JSON.stringify(payload)}`); try { await takaro.gameserver.gameServerControllerBanPlayer(gameServerId, target.playerId, payload); } catch (err) { - console.error(`utils:ban failed for target=${target.playerId}: ${err}`); + console.error(`toolkit:ban failed for target=${target.playerId}: ${err}`); throw new TakaroUserError('The ban could not be created right now. Please try again in a moment.'); } - console.log(`utils:ban admin=${adminName} target=${targetName} duration=${parsedDuration.humanDuration} reason=${reason}`); + console.log(`toolkit:ban admin=${adminName} target=${targetName} duration=${parsedDuration.humanDuration} reason=${reason}`); const confirmationMessage = parsedDuration.isPermanent ? `Banned ${targetName} permanently. Reason: ${reason}` : `Banned ${targetName} for ${parsedDuration.humanDuration}. Reason: ${reason}`; - console.log(`utils:ban pm=${confirmationMessage}`); + console.log(`toolkit:ban pm=${confirmationMessage}`); try { await pog.pm(confirmationMessage); } catch (err) { - console.error(`utils:ban pm failed: ${err}`); + console.error(`toolkit:ban pm failed: ${err}`); } if (mod.userConfig.broadcastBans) { diff --git a/modules/utils/src/commands/discord/index.js b/modules/server-toolkit/src/commands/discord/index.js similarity index 74% rename from modules/utils/src/commands/discord/index.js rename to modules/server-toolkit/src/commands/discord/index.js index ab67da0..b8bb549 100644 --- a/modules/utils/src/commands/discord/index.js +++ b/modules/server-toolkit/src/commands/discord/index.js @@ -1,5 +1,5 @@ import { data } from '@takaro/helpers'; -import { trimOrEmpty } from './utils-pure.js'; +import { trimOrEmpty } from './server-toolkit-pure.js'; async function main() { const { pog, module: mod } = data; @@ -7,14 +7,14 @@ async function main() { if (discordLink === '') { const message = 'This server has not configured a Discord link.'; - console.log(`utils:discord unconfigured`); + console.log(`toolkit:discord unconfigured`); console.log(message); await pog.pm(message); return; } const message = `Join our Discord: ${discordLink}`; - console.log(`utils:discord ${discordLink}`); + console.log(`toolkit:discord ${discordLink}`); console.log(message); await pog.pm(message); } diff --git a/modules/utils/src/commands/givecurrency/index.js b/modules/server-toolkit/src/commands/givecurrency/index.js similarity index 80% rename from modules/utils/src/commands/givecurrency/index.js rename to modules/server-toolkit/src/commands/givecurrency/index.js index 62a9cbc..c20ffca 100644 --- a/modules/utils/src/commands/givecurrency/index.js +++ b/modules/server-toolkit/src/commands/givecurrency/index.js @@ -2,16 +2,16 @@ import { data, takaro, TakaroUserError, checkPermission } from '@takaro/helpers' import { getCommandTargetPlayer, renderTemplate, -} from './utils-pure.js'; +} from './server-toolkit-pure.js'; import { getPlayerName, safeBroadcast, -} from './utils-helpers.js'; +} from './server-toolkit-helpers.js'; async function main() { const { pog, player, gameServerId, arguments: args, module: mod } = data; - if (!checkPermission(pog, 'UTILS_GIVE_CURRENCY')) { + if (!checkPermission(pog, 'TOOLKIT_GIVE_CURRENCY')) { throw new TakaroUserError('You do not have permission to use this command.'); } @@ -36,16 +36,16 @@ async function main() { currency: amount, }); } catch (err) { - console.error(`utils:givecurrency failed for target=${target.playerId} amount=${amount}: ${err}`); + console.error(`toolkit:givecurrency failed for target=${target.playerId} amount=${amount}: ${err}`); throw new TakaroUserError('The currency grant could not be completed. Please try again in a moment. If it keeps failing, contact a server owner.'); } - console.log(`utils:givecurrency admin=${adminName} target=${targetName} amount=${amount}`); + console.log(`toolkit:givecurrency admin=${adminName} target=${targetName} amount=${amount}`); try { await pog.pm(`Gave ${amount} currency to ${targetName}.`); } catch (err) { - console.error(`utils:givecurrency pm failed: ${err}`); + console.error(`toolkit:givecurrency pm failed: ${err}`); } if (mod.userConfig.broadcastCurrencyGrants) { diff --git a/modules/utils/src/commands/kick/index.js b/modules/server-toolkit/src/commands/kick/index.js similarity index 81% rename from modules/utils/src/commands/kick/index.js rename to modules/server-toolkit/src/commands/kick/index.js index ec08caa..2e78f9c 100644 --- a/modules/utils/src/commands/kick/index.js +++ b/modules/server-toolkit/src/commands/kick/index.js @@ -3,16 +3,16 @@ import { getCommandTargetPlayer, normalizeReason, renderTemplate, -} from './utils-pure.js'; +} from './server-toolkit-pure.js'; import { getPlayerName, safeBroadcast, -} from './utils-helpers.js'; +} from './server-toolkit-helpers.js'; async function main() { const { pog, player, gameServerId, arguments: args, module: mod } = data; - if (!checkPermission(pog, 'UTILS_KICK')) { + if (!checkPermission(pog, 'TOOLKIT_KICK')) { throw new TakaroUserError('You do not have permission to use this command.'); } @@ -40,18 +40,18 @@ async function main() { reason, }); } catch (err) { - console.error(`utils:kick failed for target=${target.playerId}: ${err}`); + console.error(`toolkit:kick failed for target=${target.playerId}: ${err}`); throw new TakaroUserError('That player could not be kicked right now. They may have just disconnected, or the game server rejected the kick.'); } - console.log(`utils:kick admin=${adminName} target=${targetName} reason=${reason}`); + console.log(`toolkit:kick admin=${adminName} target=${targetName} reason=${reason}`); const kickPm = `Kicked ${targetName}. Reason: ${reason}`; - console.log(`utils:kick pm=${kickPm}`); + console.log(`toolkit:kick pm=${kickPm}`); try { await pog.pm(kickPm); } catch (err) { - console.error(`utils:kick pm failed: ${err}`); + console.error(`toolkit:kick pm failed: ${err}`); } if (mod.userConfig.broadcastKicks) { diff --git a/modules/utils/src/commands/online/index.js b/modules/server-toolkit/src/commands/online/index.js similarity index 60% rename from modules/utils/src/commands/online/index.js rename to modules/server-toolkit/src/commands/online/index.js index d655f4d..605a7dc 100644 --- a/modules/utils/src/commands/online/index.js +++ b/modules/server-toolkit/src/commands/online/index.js @@ -1,12 +1,12 @@ import { data } from '@takaro/helpers'; -import { formatOnlinePlayersLine } from './utils-pure.js'; -import { fetchOnlinePlayers } from './utils-helpers.js'; +import { formatOnlinePlayersLine } from './server-toolkit-pure.js'; +import { fetchOnlinePlayers } from './server-toolkit-helpers.js'; async function main() { const { gameServerId, pog } = data; const onlinePlayers = await fetchOnlinePlayers(gameServerId); const message = formatOnlinePlayersLine(onlinePlayers); - console.log(`utils:online ${message}`); + console.log(`toolkit:online ${message}`); await pog.pm(message); } diff --git a/modules/utils/src/commands/rules/index.js b/modules/server-toolkit/src/commands/rules/index.js similarity index 77% rename from modules/utils/src/commands/rules/index.js rename to modules/server-toolkit/src/commands/rules/index.js index 5e7f338..4c8c943 100644 --- a/modules/utils/src/commands/rules/index.js +++ b/modules/server-toolkit/src/commands/rules/index.js @@ -1,5 +1,5 @@ import { data } from '@takaro/helpers'; -import { compactRules } from './utils-pure.js'; +import { compactRules } from './server-toolkit-pure.js'; async function main() { const { pog, module: mod } = data; @@ -7,7 +7,7 @@ async function main() { if (rules.length === 0) { const message = 'This server has not configured any rules yet.'; - console.log(`utils:rules unconfigured`); + console.log(`toolkit:rules unconfigured`); console.log(message); await pog.pm(message); return; @@ -19,7 +19,7 @@ async function main() { } const message = lines.join('\n'); - console.log(`utils:rules ${rules.length} rules`); + console.log(`toolkit:rules ${rules.length} rules`); console.log(message); await pog.pm(message); } diff --git a/modules/utils/src/commands/serverinfo/index.js b/modules/server-toolkit/src/commands/serverinfo/index.js similarity index 75% rename from modules/utils/src/commands/serverinfo/index.js rename to modules/server-toolkit/src/commands/serverinfo/index.js index 9543198..b91bbed 100644 --- a/modules/utils/src/commands/serverinfo/index.js +++ b/modules/server-toolkit/src/commands/serverinfo/index.js @@ -1,6 +1,6 @@ import { data } from '@takaro/helpers'; -import { trimOrEmpty } from './utils-pure.js'; -import { fetchOnlinePlayers, getGameServerName } from './utils-helpers.js'; +import { trimOrEmpty } from './server-toolkit-pure.js'; +import { fetchOnlinePlayers, getGameServerName } from './server-toolkit-helpers.js'; async function main() { const { gameServerId, pog, module: mod } = data; @@ -20,7 +20,7 @@ async function main() { } const message = lines.join('\n'); - console.log(`utils:serverinfo ${message}`); + console.log(`toolkit:serverinfo ${message}`); await pog.pm(message); } diff --git a/modules/utils/src/functions/utils-helpers.js b/modules/server-toolkit/src/functions/server-toolkit-helpers.js similarity index 92% rename from modules/utils/src/functions/utils-helpers.js rename to modules/server-toolkit/src/functions/server-toolkit-helpers.js index 1520486..93e7b87 100644 --- a/modules/utils/src/functions/utils-helpers.js +++ b/modules/server-toolkit/src/functions/server-toolkit-helpers.js @@ -71,7 +71,7 @@ export async function getGameServerName(gameServerId) { const trimmed = trimOrEmptyInternal(result?.data?.data?.name); return trimmed || 'Unknown Server'; } catch (err) { - console.error(`utils-helpers: failed to load gameserver ${gameServerId}: ${err}`); + console.error(`server-toolkit-helpers: failed to load gameserver ${gameServerId}: ${err}`); return 'Unknown Server'; } } @@ -82,7 +82,7 @@ export async function getPlayerName(playerId, fallback) { const trimmed = trimOrEmptyInternal(result?.data?.data?.name); return trimmed || fallback || 'Unknown Player'; } catch (err) { - console.error(`utils-helpers: failed to load player ${playerId}: ${err}`); + console.error(`server-toolkit-helpers: failed to load player ${playerId}: ${err}`); return fallback || 'Unknown Player'; } } @@ -98,7 +98,7 @@ export async function safeBroadcast(gameServerId, message) { console.log(`[broadcast] ${trimmed}`); return true; } catch (err) { - console.error(`utils-helpers: broadcast failed: ${err}`); + console.error(`server-toolkit-helpers: broadcast failed: ${err}`); return false; } } diff --git a/modules/utils/src/functions/utils-pure.d.ts b/modules/server-toolkit/src/functions/server-toolkit-pure.d.ts similarity index 100% rename from modules/utils/src/functions/utils-pure.d.ts rename to modules/server-toolkit/src/functions/server-toolkit-pure.d.ts diff --git a/modules/utils/src/functions/utils-pure.js b/modules/server-toolkit/src/functions/server-toolkit-pure.js similarity index 100% rename from modules/utils/src/functions/utils-pure.js rename to modules/server-toolkit/src/functions/server-toolkit-pure.js diff --git a/modules/utils/test/utils.test.ts b/modules/server-toolkit/test/server-toolkit.test.ts similarity index 95% rename from modules/utils/test/utils.test.ts rename to modules/server-toolkit/test/server-toolkit.test.ts index 68b60f0..8006956 100644 --- a/modules/utils/test/utils.test.ts +++ b/modules/server-toolkit/test/server-toolkit.test.ts @@ -26,7 +26,7 @@ import { getCommandTargetPlayer, renderTemplate, parseBanDurationToken, -} from '../src/functions/utils-pure.js'; +} from '../src/functions/server-toolkit-pure.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -68,11 +68,11 @@ async function triggerCommand( return { success, logs }; } -// Pure helpers live in utils-pure.js and are the single source of truth. -// Commands import pure helpers from ./utils-pure.js (Takaro-dependent helpers from ./utils-helpers.js). -// Tests import from ../src/functions/utils-pure.js directly — same code that runs in production. +// Pure helpers live in server-toolkit-pure.js and are the single source of truth. +// Commands import pure helpers from ./server-toolkit-pure.js (Takaro-dependent helpers from ./server-toolkit-helpers.js). +// Tests import from ../src/functions/server-toolkit-pure.js directly — same code that runs in production. -describe('utils: formatOnlinePlayersLine unit behavior', () => { +describe('toolkit: formatOnlinePlayersLine unit behavior', () => { it('returns no-players message for empty list', () => { assert.equal(formatOnlinePlayersLine([]), 'No players are currently online.'); }); @@ -113,7 +113,7 @@ describe('utils: formatOnlinePlayersLine unit behavior', () => { }); }); -describe('utils: parseBanDurationToken unit behavior', () => { +describe('toolkit: parseBanDurationToken unit behavior', () => { it('"perm" is permanent', () => { const result = parseBanDurationToken('perm'); assert.ok(result !== null); @@ -180,7 +180,7 @@ describe('utils: parseBanDurationToken unit behavior', () => { }); }); -describe('utils: isBlank unit behavior', () => { +describe('toolkit: isBlank unit behavior', () => { it('returns true for undefined', () => { assert.equal(isBlank(undefined), true); }); @@ -214,7 +214,7 @@ describe('utils: isBlank unit behavior', () => { }); }); -describe('utils: trimOrEmpty unit behavior', () => { +describe('toolkit: trimOrEmpty unit behavior', () => { it('returns empty string for non-string falsy value', () => { assert.equal(trimOrEmpty(undefined), ''); assert.equal(trimOrEmpty(null), ''); @@ -229,7 +229,7 @@ describe('utils: trimOrEmpty unit behavior', () => { }); }); -describe('utils: normalizeReason unit behavior', () => { +describe('toolkit: normalizeReason unit behavior', () => { it('returns fallback for sentinel "?"', () => { assert.equal(normalizeReason('?', 'Default reason'), 'Default reason'); }); @@ -244,7 +244,7 @@ describe('utils: normalizeReason unit behavior', () => { }); }); -describe('utils: compactRules unit behavior', () => { +describe('toolkit: compactRules unit behavior', () => { it('returns empty array for non-array input', () => { assert.deepEqual(compactRules(null), []); assert.deepEqual(compactRules(undefined), []); @@ -265,7 +265,7 @@ describe('utils: compactRules unit behavior', () => { }); }); -describe('utils: getCommandTargetPlayer unit behavior', () => { +describe('toolkit: getCommandTargetPlayer unit behavior', () => { it('returns null for null or non-object input', () => { assert.equal(getCommandTargetPlayer(null), null); assert.equal(getCommandTargetPlayer('string'), null); @@ -299,7 +299,7 @@ describe('utils: getCommandTargetPlayer unit behavior', () => { }); }); -describe('utils: renderTemplate unit behavior', () => { +describe('toolkit: renderTemplate unit behavior', () => { it('returns source unchanged when no placeholders present', () => { assert.equal(renderTemplate('Hello world', {}), 'Hello world'); }); @@ -326,7 +326,7 @@ describe('utils: renderTemplate unit behavior', () => { // Suite 1: Public commands -------------------------------------------------- -describe('utils: public commands', () => { +describe('toolkit: public commands', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -466,7 +466,7 @@ describe('utils: public commands', () => { // Suite 2: Empty config fallbacks ------------------------------------------- -describe('utils: empty config fallbacks', () => { +describe('toolkit: empty config fallbacks', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -564,7 +564,7 @@ describe('utils: empty config fallbacks', () => { // Suite 2b: /rules whitespace-filter test ------------------------------------ -describe('utils: /rules whitespace-filter', () => { +describe('toolkit: /rules whitespace-filter', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -629,7 +629,7 @@ describe('utils: /rules whitespace-filter', () => { // Suite 3: Admin commands (kick / ban / givecurrency) ----------------------- -describe('utils: admin commands', () => { +describe('toolkit: admin commands', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; @@ -679,9 +679,9 @@ describe('utils: admin commands', () => { ); // player[0] = kick admin, player[1] = ban admin, player[2] = currency admin & target - kickRoleId = await assignPermissions(client, ctx.players[0].playerId, ctx.gameServer.id, ['UTILS_KICK']); - banRoleId = await assignPermissions(client, ctx.players[1].playerId, ctx.gameServer.id, ['UTILS_BAN']); - currencyRoleId = await assignPermissions(client, ctx.players[0].playerId, ctx.gameServer.id, ['UTILS_GIVE_CURRENCY']); + kickRoleId = await assignPermissions(client, ctx.players[0].playerId, ctx.gameServer.id, ['TOOLKIT_KICK']); + banRoleId = await assignPermissions(client, ctx.players[1].playerId, ctx.gameServer.id, ['TOOLKIT_BAN']); + currencyRoleId = await assignPermissions(client, ctx.players[0].playerId, ctx.gameServer.id, ['TOOLKIT_GIVE_CURRENCY']); // Give player[0] some currency for self-givecurrency test await client.playerOnGameserver.playerOnGameServerControllerAddCurrency( @@ -710,7 +710,7 @@ describe('utils: admin commands', () => { // --- /kick tests --- - it('/kick denied without UTILS_KICK permission', async () => { + it('/kick denied without TOOLKIT_KICK permission', async () => { // player[2] has no kick permission const player = ctx.players[2]!; @@ -728,7 +728,7 @@ describe('utils: admin commands', () => { }); it('/kick self-targeting is denied', async () => { - // player[0] has UTILS_KICK, tries to kick themselves + // player[0] has TOOLKIT_KICK, tries to kick themselves const admin = ctx.players[0]!; const { success, logs } = await triggerCommand( @@ -744,7 +744,7 @@ describe('utils: admin commands', () => { ); }); - it('/kick works with UTILS_KICK permission and logs the kick', async () => { + it('/kick works with TOOLKIT_KICK permission and logs the kick', async () => { const admin = ctx.players[0]!; const { success, logs } = await triggerCommand( @@ -755,7 +755,7 @@ describe('utils: admin commands', () => { assert.equal(success, true, `Expected kick to succeed, logs: ${JSON.stringify(logs)}`); assert.ok( - logs.some((msg) => msg.includes('utils:kick')), + logs.some((msg) => msg.includes('toolkit:kick')), `Expected kick log, got: ${JSON.stringify(logs)}`, ); assert.ok( @@ -877,7 +877,7 @@ describe('utils: admin commands', () => { // --- /ban tests --- - it('/ban denied without UTILS_BAN permission', async () => { + it('/ban denied without TOOLKIT_BAN permission', async () => { // player[2] has no ban permission const player = ctx.players[2]!; @@ -954,14 +954,14 @@ describe('utils: admin commands', () => { assert.equal(success, true, `Expected temp ban to succeed, logs: ${JSON.stringify(logs)}`); assert.ok( - logs.some((msg) => msg.includes('utils:ban')), + logs.some((msg) => msg.includes('toolkit:ban')), `Expected ban log, got: ${JSON.stringify(logs)}`, ); // VI-13: Parse payload log and assert expiresAt IS PRESENT for temp ban - const payloadLog = logs.find((msg) => msg.startsWith('utils:ban payload=')); + const payloadLog = logs.find((msg) => msg.startsWith('toolkit:ban payload=')); assert.ok(payloadLog, `Expected payload log line, got: ${JSON.stringify(logs)}`); - const payloadStr = payloadLog!.replace('utils:ban payload=', ''); + const payloadStr = payloadLog!.replace('toolkit:ban payload=', ''); const payload = JSON.parse(payloadStr); assert.ok('expiresAt' in payload, `Expected expiresAt in temp ban payload, got: ${JSON.stringify(payload)}`); assert.ok(payload.expiresAt !== null && payload.expiresAt !== undefined, `expiresAt should not be null/undefined`); @@ -992,9 +992,9 @@ describe('utils: admin commands', () => { ); // VI-13: Parse payload log and assert expiresAt is ABSENT for perm ban - const payloadLog = logs.find((msg) => msg.startsWith('utils:ban payload=')); + const payloadLog = logs.find((msg) => msg.startsWith('toolkit:ban payload=')); assert.ok(payloadLog, `Expected payload log line, got: ${JSON.stringify(logs)}`); - const payloadStr = payloadLog!.replace('utils:ban payload=', ''); + const payloadStr = payloadLog!.replace('toolkit:ban payload=', ''); const payload = JSON.parse(payloadStr); assert.ok(!('expiresAt' in payload), `Expected no expiresAt in perm ban payload, got: ${JSON.stringify(payload)}`); @@ -1061,7 +1061,7 @@ describe('utils: admin commands', () => { // --- /givecurrency tests --- - it('/givecurrency denied without UTILS_GIVE_CURRENCY permission', async () => { + it('/givecurrency denied without TOOLKIT_GIVE_CURRENCY permission', async () => { // player[1] has no currency permission; target player[0] (online throughout) const player = ctx.players[1]!; @@ -1121,7 +1121,7 @@ describe('utils: admin commands', () => { assert.equal(success, true, `Expected givecurrency to succeed, logs: ${JSON.stringify(logs)}`); assert.ok( - logs.some((msg) => msg.includes('utils:givecurrency')), + logs.some((msg) => msg.includes('toolkit:givecurrency')), `Expected givecurrency log, got: ${JSON.stringify(logs)}`, ); }); @@ -1137,7 +1137,7 @@ describe('utils: admin commands', () => { assert.equal(success, true, `Expected self-givecurrency to succeed, logs: ${JSON.stringify(logs)}`); assert.ok( - logs.some((msg) => msg.includes('utils:givecurrency')), + logs.some((msg) => msg.includes('toolkit:givecurrency')), `Expected givecurrency log for self, got: ${JSON.stringify(logs)}`, ); }); @@ -1162,7 +1162,7 @@ describe('utils: admin commands', () => { // Suite 4: /online with no players ------------------------------------------ -describe('utils: /online no-players fallback', () => { +describe('toolkit: /online no-players fallback', () => { let client: Client; let ctx: MockServerContext; let moduleId: string; diff --git a/modules/vote-restart/module.json b/modules/vote-restart/module.json index 24d6395..cc9dbf4 100644 --- a/modules/vote-restart/module.json +++ b/modules/vote-restart/module.json @@ -1,7 +1,7 @@ { - "name": "test-vote-restart", - "description": "Vote Restart — players vote democratically to restart the server. Dynamic threshold based on online non-immune players.", - "version": "latest", + "name": "vote-restart", + "description": "Vote Restart \u2014 players vote democratically to restart the server. Dynamic threshold based on online non-immune players.", + "version": "1.0.0", "supportedGames": [ "all" ], diff --git a/package.json b/package.json index 08916ab..e9a2040 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,13 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.build.json", - "pretest": "npm run build", - "test": "T_WS_CONTINUOUS_RECONNECT=false T_WS_HEARTBEAT_INTERVAL_MS=120000 LOGGING_LEVEL=warn node --test-force-exit --test-concurrency 1 --import=ts-node-maintained/register/esm --test 'modules/*/test/*.test.ts'", + "pretest": "npm run build && npm run build:registry", + "test": "T_WS_CONTINUOUS_RECONNECT=false T_WS_HEARTBEAT_INTERVAL_MS=120000 LOGGING_LEVEL=warn node --test-force-exit --test-concurrency 1 --import=ts-node-maintained/register/esm --test '{modules/*/test,test/helpers}/*.test.ts'", "typecheck": "tsc --noEmit", "module:to-json": "node dist/scripts/module-to-json.js", "module:from-json": "node dist/scripts/json-to-module.js", - "module:migrate": "node dist/scripts/migrate-module.js" + "module:migrate": "node dist/scripts/migrate-module.js", + "build:registry": "node dist/scripts/build-registry.js" }, "dependencies": { "@takaro/apiclient": "0.4.17", diff --git a/registry.config.json b/registry.config.json new file mode 100644 index 0000000..f083a83 --- /dev/null +++ b/registry.config.json @@ -0,0 +1,4 @@ +{ + "name": "Takaro Official Module Registry", + "description": "Curated modules maintained by the Takaro team." +} diff --git a/src/scripts/build-registry.ts b/src/scripts/build-registry.ts new file mode 100644 index 0000000..3b87a32 --- /dev/null +++ b/src/scripts/build-registry.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * Build the registry manifest and per-version JSON files. + * + * Reads registry-level metadata from registry.config.json at the repo root, + * then for each module under modules/ calls buildModuleExport() and writes: + * dist/registry/registry.json — the RegistryManifest + * dist/registry/{moduleName}/{version}.json — TakaroModuleExport per module + * + * Usage: node dist/scripts/build-registry.js + */ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { buildModuleExport, NO_DESCRIPTION } from './module-to-json.js'; +import { RegistryManifest, RegistryManifestModule } from '../types/module.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); +const MODULES_DIR = path.join(REPO_ROOT, 'modules'); +const DIST_REGISTRY_DIR = path.join(REPO_ROOT, 'dist', 'registry'); +const REGISTRY_CONFIG_PATH = path.join(REPO_ROOT, 'registry.config.json'); + +const MODULE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9._-]+)?(?:\+[a-zA-Z0-9._-]+)?$/; +const MAX_DESCRIPTION_LENGTH = 500; + +function isSemVer(version: string): boolean { + return SEMVER_PATTERN.test(version); +} + +function validateModuleName(name: string): void { + if (!MODULE_NAME_PATTERN.test(name)) { + throw new Error(`Module name '${name}' does not match required pattern /^[a-zA-Z0-9_-]+$/`); + } + if (name.length > 100) { + throw new Error(`Module name '${name}' exceeds 100 character limit (length: ${name.length})`); + } +} + +interface RegistryConfig { + name: string; + description?: string; +} + +function readRegistryConfig(): RegistryConfig { + if (!fs.existsSync(REGISTRY_CONFIG_PATH)) { + // Warn and use defaults so that a fresh clone can still run build:registry + // without needing to create registry.config.json first. + console.warn( + `WARNING: registry.config.json not found at ${REGISTRY_CONFIG_PATH}. ` + + `Using default registry name. Create registry.config.json to customise.` + ); + return { name: 'Unnamed Registry', description: undefined }; + } + const raw = fs.readFileSync(REGISTRY_CONFIG_PATH, 'utf-8'); + const config = JSON.parse(raw) as RegistryConfig; + if (!config.name || typeof config.name !== 'string') { + throw new Error('registry.config.json must have a "name" string field'); + } + if (config.name.length > 200) { + throw new Error(`Registry name exceeds 200 character limit (length: ${config.name.length})`); + } + if (config.description && config.description.length > 1000) { + throw new Error(`Registry description exceeds 1000 character limit (length: ${config.description.length})`); + } + return config; +} + +function getModuleDirs(): string[] { + const entries = fs.readdirSync(MODULES_DIR, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); +} + +function ensureDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function main(): void { + const config = readRegistryConfig(); + const moduleDirNames = getModuleDirs(); + + console.log(`Building registry for ${moduleDirNames.length} modules...`); + + // Safety invariant: confirm the path ends with 'dist/registry' before wiping it. + // This prevents accidental data loss if REPO_ROOT is misconfigured. + if (!DIST_REGISTRY_DIR.endsWith('dist/registry')) { + throw new Error(`Refusing to rmSync ${DIST_REGISTRY_DIR}: expected a dist/registry path`); + } + // Clear stale output — ensures removed/renamed modules don't persist in dist/ + fs.rmSync(DIST_REGISTRY_DIR, { recursive: true, force: true }); + ensureDir(DIST_REGISTRY_DIR); + + const manifestModules: RegistryManifestModule[] = []; + + for (const dirName of moduleDirNames) { + const moduleDir = path.join(MODULES_DIR, dirName); + console.log(` Processing: ${dirName}`); + + const moduleExport = buildModuleExport(moduleDir); + + // Extract the first (and currently only) version + const version = moduleExport.versions[0]; + if (!version) { + throw new Error(`Module '${dirName}' has no versions`); + } + + const moduleName = moduleExport.name; + const semver = version.tag; + + // Validate module name + validateModuleName(moduleName); + + // Validate semver + if (!isSemVer(semver)) { + throw new Error( + `Module '${dirName}' has version '${semver}' which is not valid semver. ` + + `Only semver versions are accepted (e.g. 1.0.0).`, + ); + } + + // Validate description length + if (version.description && version.description.length > MAX_DESCRIPTION_LENGTH) { + throw new Error( + `Module '${dirName}' description exceeds ${MAX_DESCRIPTION_LENGTH} character limit ` + + `(length: ${version.description.length})`, + ); + } + + // Write per-version JSON: dist/registry/{moduleName}/{version}.json + const moduleDistDir = path.join(DIST_REGISTRY_DIR, moduleName); + ensureDir(moduleDistDir); + const versionJsonPath = path.join(moduleDistDir, `${semver}.json`); + fs.writeFileSync(versionJsonPath, JSON.stringify(moduleExport, null, 2)); + console.log(` Written: ${moduleName}/${semver}.json`); + + manifestModules.push({ + name: moduleName, + latestVersion: semver, + versions: [semver], + description: version.description !== NO_DESCRIPTION ? version.description : undefined, + }); + } + + // Write registry.json manifest + const manifest: RegistryManifest = { + name: config.name, + description: config.description, + modules: manifestModules, + }; + + const manifestPath = path.join(DIST_REGISTRY_DIR, 'registry.json'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + console.log(`\nRegistry manifest written to: ${manifestPath}`); + console.log(`Total modules: ${manifestModules.length}`); +} + +main(); diff --git a/src/scripts/json-to-module.ts b/src/scripts/json-to-module.ts index 00eb3a8..e085e46 100644 --- a/src/scripts/json-to-module.ts +++ b/src/scripts/json-to-module.ts @@ -57,7 +57,12 @@ if (!data.versions || !Array.isArray(data.versions)) { const { name, author = 'Unknown', supportedGames = ['all'] } = data; // Validate module name (prevents empty, whitespace, and path traversal) -validateEntityName(name, 'module'); +try { + validateEntityName(name, 'module'); +} catch (err) { + console.error(`ERROR: ${(err as Error).message}`); + process.exit(1); +} // Pick the "latest" version, or the first one const version: ModuleVersion | undefined = @@ -72,41 +77,46 @@ const repoDir = path.resolve(__dirname, '..', '..'); const outputDir = outputDirArg ?? path.join(repoDir, 'modules', name); // Validate all entity names and function fields before writing any files -if (version.commands) { - for (const cmd of version.commands) { - validateEntityName(cmd.name, 'command'); - if (!cmd.function || typeof cmd.function !== 'string') { - console.error(`ERROR: command '${cmd.name}' is missing required field 'function' (JS code)`); - process.exit(1); +try { + if (version.commands) { + for (const cmd of version.commands) { + validateEntityName(cmd.name, 'command'); + if (!cmd.function || typeof cmd.function !== 'string') { + console.error(`ERROR: command '${cmd.name}' is missing required field 'function' (JS code)`); + process.exit(1); + } } } -} -if (version.hooks) { - for (const hook of version.hooks) { - validateEntityName(hook.name, 'hook'); - if (!hook.function || typeof hook.function !== 'string') { - console.error(`ERROR: hook '${hook.name}' is missing required field 'function' (JS code)`); - process.exit(1); + if (version.hooks) { + for (const hook of version.hooks) { + validateEntityName(hook.name, 'hook'); + if (!hook.function || typeof hook.function !== 'string') { + console.error(`ERROR: hook '${hook.name}' is missing required field 'function' (JS code)`); + process.exit(1); + } } } -} -if (version.cronJobs) { - for (const cron of version.cronJobs) { - validateEntityName(cron.name, 'cronJob'); - if (!cron.function || typeof cron.function !== 'string') { - console.error(`ERROR: cronJob '${cron.name}' is missing required field 'function' (JS code)`); - process.exit(1); + if (version.cronJobs) { + for (const cron of version.cronJobs) { + validateEntityName(cron.name, 'cronJob'); + if (!cron.function || typeof cron.function !== 'string') { + console.error(`ERROR: cronJob '${cron.name}' is missing required field 'function' (JS code)`); + process.exit(1); + } } } -} -if (version.functions) { - for (const fn of version.functions) { - validateEntityName(fn.name, 'function'); - if (!fn.function || typeof fn.function !== 'string') { - console.error(`ERROR: function '${fn.name}' is missing required field 'function' (JS code)`); - process.exit(1); + if (version.functions) { + for (const fn of version.functions) { + validateEntityName(fn.name, 'function'); + if (!fn.function || typeof fn.function !== 'string') { + console.error(`ERROR: function '${fn.name}' is missing required field 'function' (JS code)`); + process.exit(1); + } } } +} catch (err) { + console.error(`ERROR: ${(err as Error).message}`); + process.exit(1); } console.error(`Extracting module '${name}' to ${outputDir}`); diff --git a/src/scripts/module-to-json.ts b/src/scripts/module-to-json.ts index fd6d5c1..95f6caa 100644 --- a/src/scripts/module-to-json.ts +++ b/src/scripts/module-to-json.ts @@ -5,6 +5,7 @@ */ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import { TakaroModuleExport, LocalModuleJson, @@ -15,208 +16,235 @@ import { } from '../types/module.js'; import { validateEntityName } from '../utils/validate.js'; -const moduleDir = process.argv[2]; -const outputFile = process.argv[3]; - -if (!moduleDir) { - console.error('Usage: module-to-json.js [output-file]'); - process.exit(1); -} - -if (!fs.existsSync(moduleDir) || !fs.statSync(moduleDir).isDirectory()) { - console.error(`ERROR: ${moduleDir} is not a directory`); - process.exit(1); -} - -const moduleJsonPath = path.join(moduleDir, 'module.json'); -if (!fs.existsSync(moduleJsonPath)) { - console.error(`ERROR: ${moduleJsonPath} not found`); - process.exit(1); -} - -let mod: LocalModuleJson; -try { - mod = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8')) as LocalModuleJson; -} catch (err) { - console.error(`ERROR: Failed to parse '${moduleJsonPath}': ${(err as Error).message}`); - process.exit(1); -} - -validateEntityName(mod.name ?? '', 'module'); - -// Validate that commands/hooks/cronJobs/functions if present are plain objects (not arrays) -for (const field of ['commands', 'hooks', 'cronJobs', 'functions'] as const) { - const val = mod[field]; - if (val !== undefined && (typeof val !== 'object' || Array.isArray(val))) { - console.error(`ERROR: '${moduleJsonPath}' field '${field}' must be a plain object (not an array)`); - process.exit(1); +/** + * Resolve a path to its real (symlink-free) absolute path, with a friendly error on ENOENT. + */ +function safeRealpath(p: string, label: string): string { + try { + return fs.realpathSync(p); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error(`${label} not found (or is a broken symlink): ${p}`); + } + throw e; } } /** * Read the JS code from a file path relative to the module root. - * Exits with an error if the file doesn't exist or path escapes module directory. + * Throws an error if the file doesn't exist or path escapes module directory. */ -function readJsFile(relPath: string, label: string): string { +function readJsFile(moduleDir: string, relPath: string, label: string): string { const resolvedModuleDir = path.resolve(moduleDir); const absPath = path.resolve(resolvedModuleDir, relPath); - if (!absPath.startsWith(resolvedModuleDir + path.sep) && absPath !== resolvedModuleDir) { - console.error(`ERROR: Path '${relPath}' escapes module directory (for ${label})`); - process.exit(1); - } if (!fs.existsSync(absPath)) { - console.error(`ERROR: Referenced file '${relPath}' not found (for ${label}). Expected at ${absPath}`); - process.exit(1); + throw new Error(`Referenced file '${relPath}' not found (for ${label}). Expected at ${absPath}`); } - // Resolve symlinks and re-check containment to prevent symlink bypass attacks - const realModuleDir = fs.realpathSync(resolvedModuleDir); - const realAbsPath = fs.realpathSync(absPath); + // Resolve symlinks and check containment — this catches both plain path-traversal and symlink bypass attacks. + // Note: existsSync passes for broken symlinks but realpathSync throws ENOENT on the target, + // so we convert that case to the explicit "not found" message. + const realModuleDir = safeRealpath(resolvedModuleDir, `Module directory '${resolvedModuleDir}'`); + const realAbsPath = safeRealpath(absPath, `Referenced file '${relPath}' (for ${label})`); if (!realAbsPath.startsWith(realModuleDir + path.sep) && realAbsPath !== realModuleDir) { - console.error(`ERROR: Path '${relPath}' escapes module directory via symlink (for ${label})`); - process.exit(1); + throw new Error(`Path '${relPath}' escapes module directory (for ${label})`); } return fs.readFileSync(realAbsPath, 'utf-8'); } -// Config schema — inline object or default -let configSchema: string; -if (mod.config !== undefined) { - if (typeof mod.config !== 'object' || Array.isArray(mod.config)) { - console.error(`ERROR: '${moduleJsonPath}' field 'config' must be a plain object (not a string or array)`); - process.exit(1); +/** + * Sentinel used when a module.json has no description field. + * Exported so build-registry.ts can test for it without a hard-coded string literal. + */ +export const NO_DESCRIPTION = 'No description'; + +/** + * Convert a local module directory into a TakaroModuleExport object. + * This is the reusable core — the CLI is a thin wrapper on top. + * + * @param moduleDir - Path to the module directory (containing module.json) + * @returns TakaroModuleExport ready for import into Takaro or for the registry + */ +export function buildModuleExport(moduleDir: string): TakaroModuleExport { + if (!fs.existsSync(moduleDir) || !fs.statSync(moduleDir).isDirectory()) { + throw new Error(`${moduleDir} is not a directory`); } -} -if (mod.config && Object.keys(mod.config).length > 0) { - configSchema = JSON.stringify(mod.config); -} else { - configSchema = - '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{},"required":[],"additionalProperties":false}'; -} -// UI schema -if (mod.uiSchema !== undefined) { - if (typeof mod.uiSchema !== 'object' || Array.isArray(mod.uiSchema)) { - console.error(`ERROR: '${moduleJsonPath}' field 'uiSchema' must be a plain object (not a string or array)`); - process.exit(1); + const moduleJsonPath = path.join(moduleDir, 'module.json'); + if (!fs.existsSync(moduleJsonPath)) { + throw new Error(`${moduleJsonPath} not found`); } -} -let uiSchema: string; -if (mod.uiSchema && Object.keys(mod.uiSchema).length > 0) { - uiSchema = JSON.stringify(mod.uiSchema); -} else { - uiSchema = '{}'; -} -// Commands -const commands: ModuleCommand[] = []; -if (mod.commands) { - for (const [name, def] of Object.entries(mod.commands)) { - validateEntityName(name, 'command'); - if (!def.function) { - console.error(`ERROR: command '${name}' is missing required field 'function' (path to JS file)`); - process.exit(1); + let mod: LocalModuleJson; + try { + mod = JSON.parse(fs.readFileSync(moduleJsonPath, 'utf-8')) as LocalModuleJson; + } catch (err) { + throw new Error(`Failed to parse '${moduleJsonPath}': ${(err as Error).message}`); + } + + validateEntityName(mod.name ?? '', 'module'); + + // Validate that commands/hooks/cronJobs/functions if present are plain objects (not arrays) + for (const field of ['commands', 'hooks', 'cronJobs', 'functions'] as const) { + const val = mod[field]; + if (val !== undefined && (typeof val !== 'object' || Array.isArray(val))) { + throw new Error(`'${moduleJsonPath}' field '${field}' must be a plain object (not an array)`); } - const code = readJsFile(def.function, `command '${name}'`); - commands.push({ - name, - trigger: def.trigger ?? name, - description: def.description ?? null, - helpText: def.helpText ?? 'No help text available', - function: code, - arguments: def.arguments ?? [], - }); } -} -// Hooks -const hooks: ModuleHook[] = []; -if (mod.hooks) { - for (const [name, def] of Object.entries(mod.hooks)) { - validateEntityName(name, 'hook'); - if (!def.eventType) { - console.error(`ERROR: hook '${name}' is missing required field 'eventType'`); - process.exit(1); + // Config schema — inline object or default + if (mod.config !== undefined) { + if (typeof mod.config !== 'object' || Array.isArray(mod.config)) { + throw new Error(`'${moduleJsonPath}' field 'config' must be a plain object (not a string or array)`); } - if (!def.function) { - console.error(`ERROR: hook '${name}' is missing required field 'function' (path to JS file)`); - process.exit(1); + } + let configSchema: string; + if (mod.config && Object.keys(mod.config).length > 0) { + configSchema = JSON.stringify(mod.config); + } else { + configSchema = + '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{},"required":[],"additionalProperties":false}'; + } + + // UI schema + if (mod.uiSchema !== undefined) { + if (typeof mod.uiSchema !== 'object' || Array.isArray(mod.uiSchema)) { + throw new Error(`'${moduleJsonPath}' field 'uiSchema' must be a plain object (not a string or array)`); } - const code = readJsFile(def.function, `hook '${name}'`); - hooks.push({ - name, - eventType: def.eventType, - description: def.description ?? null, - regex: def.regex ?? null, - function: code, - }); } -} + let uiSchema: string; + if (mod.uiSchema && Object.keys(mod.uiSchema).length > 0) { + uiSchema = JSON.stringify(mod.uiSchema); + } else { + uiSchema = '{}'; + } -// CronJobs -const cronJobs: ModuleCronJob[] = []; -if (mod.cronJobs) { - for (const [name, def] of Object.entries(mod.cronJobs)) { - validateEntityName(name, 'cronJob'); - if (!def.temporalValue) { - console.error(`ERROR: cronJob '${name}' is missing required field 'temporalValue'`); - process.exit(1); + // Commands + const commands: ModuleCommand[] = []; + if (mod.commands) { + for (const [name, def] of Object.entries(mod.commands)) { + validateEntityName(name, 'command'); + if (!def.function) { + throw new Error(`command '${name}' is missing required field 'function' (path to JS file)`); + } + const code = readJsFile(moduleDir, def.function, `command '${name}'`); + commands.push({ + name, + trigger: def.trigger ?? name, + description: def.description ?? null, + helpText: def.helpText ?? 'No help text available', + function: code, + arguments: def.arguments ?? [], + }); } - if (!def.function) { - console.error(`ERROR: cronJob '${name}' is missing required field 'function' (path to JS file)`); - process.exit(1); + } + + // Hooks + const hooks: ModuleHook[] = []; + if (mod.hooks) { + for (const [name, def] of Object.entries(mod.hooks)) { + validateEntityName(name, 'hook'); + if (!def.eventType) { + throw new Error(`hook '${name}' is missing required field 'eventType'`); + } + if (!def.function) { + throw new Error(`hook '${name}' is missing required field 'function' (path to JS file)`); + } + const code = readJsFile(moduleDir, def.function, `hook '${name}'`); + hooks.push({ + name, + eventType: def.eventType, + description: def.description ?? null, + regex: def.regex ?? null, + function: code, + }); } - const code = readJsFile(def.function, `cronJob '${name}'`); - cronJobs.push({ - name, - temporalValue: def.temporalValue, - description: def.description ?? null, - function: code, - }); } -} -// Functions -const functions: ModuleFunction[] = []; -if (mod.functions) { - for (const [name, def] of Object.entries(mod.functions)) { - validateEntityName(name, 'function'); - if (!def.function) { - console.error(`ERROR: function '${name}' is missing required field 'function' (path to JS file)`); - process.exit(1); + // CronJobs + const cronJobs: ModuleCronJob[] = []; + if (mod.cronJobs) { + for (const [name, def] of Object.entries(mod.cronJobs)) { + validateEntityName(name, 'cronJob'); + if (!def.temporalValue) { + throw new Error(`cronJob '${name}' is missing required field 'temporalValue'`); + } + if (!def.function) { + throw new Error(`cronJob '${name}' is missing required field 'function' (path to JS file)`); + } + const code = readJsFile(moduleDir, def.function, `cronJob '${name}'`); + cronJobs.push({ + name, + temporalValue: def.temporalValue, + description: def.description ?? null, + function: code, + }); } - const code = readJsFile(def.function, `function '${name}'`); - functions.push({ - name, - function: code, - }); } + + // Functions + const functions: ModuleFunction[] = []; + if (mod.functions) { + for (const [name, def] of Object.entries(mod.functions)) { + validateEntityName(name, 'function'); + if (!def.function) { + throw new Error(`function '${name}' is missing required field 'function' (path to JS file)`); + } + const code = readJsFile(moduleDir, def.function, `function '${name}'`); + functions.push({ + name, + function: code, + }); + } + } + + return { + takaroVersion: '0.0.0', + name: mod.name, + author: mod.author ?? 'Unknown', + supportedGames: mod.supportedGames ?? ['all'], + versions: [ + { + tag: mod.version ?? 'latest', + description: mod.description ?? NO_DESCRIPTION, + configSchema, + uiSchema, + commands, + hooks, + cronJobs, + functions, + permissions: mod.permissions ?? [], + }, + ], + }; } -const result: TakaroModuleExport = { - takaroVersion: '0.0.0', - name: mod.name, - author: mod.author ?? 'Unknown', - supportedGames: mod.supportedGames ?? ['all'], - versions: [ - { - tag: mod.version ?? 'latest', - description: mod.description ?? 'No description', - configSchema, - uiSchema, - commands, - hooks, - cronJobs, - functions, - permissions: mod.permissions ?? [], - }, - ], -}; - -const json = JSON.stringify(result, null, 2); - -if (outputFile) { - fs.writeFileSync(outputFile, json); - console.error(`Written to ${outputFile}`); -} else { - console.log(json); +// CLI entry point — only runs when executed directly (not when imported as a module). +// In ESM, compare the resolved entry path against this file's path. +const _isMain = process.argv[1] === fileURLToPath(import.meta.url); + +if (_isMain) { + const moduleDir = process.argv[2]; + const outputFile = process.argv[3]; + + if (!moduleDir) { + console.error('Usage: module-to-json.js [output-file]'); + process.exit(1); + } + + let result: TakaroModuleExport; + try { + result = buildModuleExport(moduleDir); + } catch (err) { + console.error(`ERROR: ${(err as Error).message}`); + process.exit(1); + } + + const json = JSON.stringify(result, null, 2); + + if (outputFile) { + fs.writeFileSync(outputFile, json); + console.error(`Written to ${outputFile}`); + } else { + console.log(json); + } } diff --git a/src/types/module.ts b/src/types/module.ts index fd9f515..f64ebb8 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -68,6 +68,21 @@ export interface TakaroModuleExport { versions: ModuleVersion[]; } +/** A single module entry in the registry manifest */ +export interface RegistryManifestModule { + name: string; + latestVersion: string; + versions: string[]; + description?: string; +} + +/** The top-level registry manifest served at {base}/registry.json */ +export interface RegistryManifest { + name: string; + description?: string; + modules: RegistryManifestModule[]; +} + /** New consolidated module.json format — all metadata in one file */ export interface LocalCommandDef { trigger: string; diff --git a/src/utils/validate.ts b/src/utils/validate.ts index cf75637..c6d3c36 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -1,14 +1,12 @@ /** * Validates that an entity name is not empty/whitespace and does not contain path traversal characters. - * Calls process.exit(1) on failure (consistent with CLI script pattern). + * Throws an Error on failure — callers that need CLI exit-1 behavior should catch and exit. */ export function validateEntityName(entityName: string, entityType: string): void { if (!entityName || entityName.trim() === '') { - console.error(`ERROR: ${entityType} name must not be empty`); - process.exit(1); + throw new Error(`${entityType} name must not be empty`); } if (entityName.includes('/') || entityName.includes('\\') || entityName.includes('..')) { - console.error(`ERROR: ${entityType} name '${entityName}' contains invalid path characters`); - process.exit(1); + throw new Error(`${entityType} name '${entityName}' contains invalid path characters`); } } diff --git a/test/helpers/modules-guard.test.ts b/test/helpers/modules-guard.test.ts new file mode 100644 index 0000000..e7254d2 --- /dev/null +++ b/test/helpers/modules-guard.test.ts @@ -0,0 +1,154 @@ +/** + * Pure unit tests for assertTestSafeHost. + * These run without a live Takaro stack — they only test logic. + * Run with: node --test --import=ts-node-maintained/register/esm 'test/helpers/modules-guard.test.ts' + */ + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { assertTestSafeHost } from './modules.js'; + +function withEnv(overrides: Record, fn: () => void) { + const saved: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + saved[key] = process.env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +describe('assertTestSafeHost', () => { + it('throws for production host api.takaro.io', () => { + withEnv( + { TAKARO_HOST: 'https://api.takaro.io', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.throws(() => assertTestSafeHost(), /refused/); + }, + ); + }); + + it('throws for prod.dev host', () => { + withEnv( + { TAKARO_HOST: 'https://prod.dev', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.throws(() => assertTestSafeHost(), /refused/); + }, + ); + }); + + it('passes for localhost', () => { + withEnv( + { TAKARO_HOST: 'http://localhost:13000', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('passes for api.next.takaro.dev', () => { + withEnv( + { TAKARO_HOST: 'https://api.next.takaro.dev', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('passes for single-label Docker hostname takaro_api', () => { + withEnv( + { TAKARO_HOST: 'http://takaro_api', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('passes when TAKARO_TEST_ALLOW_ANY_HOST=1', () => { + withEnv( + { TAKARO_HOST: 'https://api.takaro.io', TAKARO_TEST_ALLOW_ANY_HOST: '1', TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('passes when host matches TAKARO_TEST_HOST_ALLOWLIST entry', () => { + withEnv( + { + TAKARO_HOST: 'https://my-custom-staging.example.com', + TAKARO_TEST_ALLOW_ANY_HOST: undefined, + TAKARO_TEST_HOST_ALLOWLIST: 'my-custom-staging', + }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('returns silently when TAKARO_HOST is not set', () => { + withEnv( + { TAKARO_HOST: undefined, TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('passes when host matches the third entry of a multi-entry TAKARO_TEST_HOST_ALLOWLIST', () => { + withEnv( + { + TAKARO_HOST: 'https://my-staging.example.com', + TAKARO_TEST_ALLOW_ANY_HOST: undefined, + TAKARO_TEST_HOST_ALLOWLIST: 'foo,bar,my-staging', + }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('passes when TAKARO_TEST_HOST_ALLOWLIST entries have surrounding whitespace', () => { + withEnv( + { + TAKARO_HOST: 'https://my-staging.example.com', + TAKARO_TEST_ALLOW_ANY_HOST: undefined, + TAKARO_TEST_HOST_ALLOWLIST: ' my-staging , spaced ', + }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); + + it('throws when TAKARO_HOST is not a valid URL and no safe substring matches', () => { + withEnv( + { TAKARO_HOST: 'not-a-url-at-all', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.throws(() => assertTestSafeHost(), /refused/); + }, + ); + }); + + it('passes for host containing -staging- substring', () => { + withEnv( + { TAKARO_HOST: 'https://host-staging-1.example.com', TAKARO_TEST_ALLOW_ANY_HOST: undefined, TAKARO_TEST_HOST_ALLOWLIST: undefined }, + () => { + assert.doesNotThrow(() => assertTestSafeHost()); + }, + ); + }); +}); diff --git a/test/helpers/modules.ts b/test/helpers/modules.ts index dfda9a1..b4cbfd9 100644 --- a/test/helpers/modules.ts +++ b/test/helpers/modules.ts @@ -19,6 +19,58 @@ export interface InstallModuleConfig { systemConfig?: Record; } +/** + * Safety guard: refuse to run pushModule/cleanupTestModules against hosts that look + * like production. Accepts TAKARO_HOST values that contain a test/dev/local/staging + * indicator, or that are explicitly allowlisted via TAKARO_TEST_HOST_ALLOWLIST + * (comma-separated substring list). + * + * Override: set TAKARO_TEST_ALLOW_ANY_HOST=1 to skip this check entirely (CI only). + */ +export function assertTestSafeHost(): void { + if (process.env.TAKARO_TEST_ALLOW_ANY_HOST === '1') return; + + const host = process.env.TAKARO_HOST ?? ''; + if (!host) return; // no host configured — let downstream calls fail naturally + + // Explicit allowlist wins + const allowlist = (process.env.TAKARO_TEST_HOST_ALLOWLIST ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (allowlist.some((pattern) => host.includes(pattern))) return; + + // Safe-by-default patterns: local, dev subdomains under takaro.dev, staging, test, ci, mock + const safePatterns = [ + 'localhost', '127.0.0.1', '0.0.0.0', 'host.docker.internal', + '.takaro.dev', // matches api.next.takaro.dev, staging.takaro.dev, etc. — but NOT prod.dev + '.dev.', '.test.', '.ci.', '.staging.', '.local', + '-dev-', '-test-', '-ci-', '-staging-', + ]; + if (safePatterns.some((p) => host.includes(p))) return; + + // Also allow single-label hostnames (no dots) — typical for internal Docker service names + // like `takaro_api` or `api` which are dev-only internal aliases. + // Single-label hostnames pass because they typically only resolve in Docker/internal networks. + // If your dev environment routes bare hostnames to public IPs, set TAKARO_TEST_HOST_ALLOWLIST + // or TAKARO_TEST_ALLOW_ANY_HOST=1 deliberately. + try { + const hostname = new URL(host).hostname; + // Only allow RFC 6761 reserved TLDs (.test, .localhost) that never resolve publicly. + // .dev is intentionally excluded — it's a real gTLD and google-prod.dev would be accepted otherwise. + const safeTLDs = ['.test', '.localhost']; + if (!hostname.includes('.') || safeTLDs.some((tld) => hostname.endsWith(tld))) return; + } catch { + // host is not a valid URL (e.g. bare hostname) — fall through to the error below + } + + throw new Error( + `pushModule/cleanupTestModules refused: TAKARO_HOST='${host}' does not match any known test/dev host pattern. ` + + `This is a safety guard to prevent wiping modules on a production domain. ` + + `To override: set TAKARO_TEST_ALLOW_ANY_HOST=1 (CI only) or add a substring to TAKARO_TEST_HOST_ALLOWLIST.` + ); +} + /** * Push a local module to Takaro via the import API. * If a module with the same name already exists, deletes it first (idempotent). @@ -28,6 +80,7 @@ export async function pushModule( client: Client, moduleDir: string, ): Promise { + assertTestSafeHost(); const absoluteModuleDir = path.resolve(moduleDir); // Convert the module dir to JSON using the compiled script @@ -146,16 +199,30 @@ export async function getCommandPrefix(client: Client, gameServerId: string): Pr } /** - * Delete all modules whose names start with 'test-' (safety net cleanup). + * Discover module names from the modules/ directory at runtime. + * This avoids maintaining a hand-maintained list — any directory added to modules/ + * is automatically included in cleanup without requiring a code change. + */ +function getKnownModuleNames(): string[] { + const modulesDir = path.join(REPO_ROOT, 'modules'); + return fs + .readdirSync(modulesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +} + +/** + * Delete all orphaned modules matching the known module names (safety net cleanup). + * Module names are discovered from the modules/ directory — no manual list to maintain. * Always re-fetches page 0 until no results remain, to avoid pagination * shift bugs when items are deleted from the current page. - * Uses search: { name: ['test-'] } for a partial-match search. * If the search fails (e.g. due to server-side errors on corrupt data), the * cleanup is skipped — this is non-fatal since module names are unique and * each test's before() also deletes specific modules by name before importing. */ export async function cleanupTestModules(client: Client): Promise { - const limit = 100; + assertTestSafeHost(); + const knownModuleNames = getKnownModuleNames(); const MAX_ITERATIONS = 50; let iterations = 0; while (true) { @@ -165,9 +232,9 @@ export async function cleanupTestModules(client: Client): Promise { let result; try { result = await client.module.moduleControllerSearch({ - limit, + limit: 100, page: 0, - search: { name: ['test-'] }, + filters: { name: knownModuleNames }, }); } catch (err) { // Non-fatal: cleanup search failed (e.g. server-side error on corrupt module data). @@ -175,7 +242,7 @@ export async function cleanupTestModules(client: Client): Promise { console.error('cleanupTestModules: search failed (non-fatal, skipping cleanup):', err); return; } - const mods = result.data.data.filter((m) => m.name.startsWith('test-')); + const mods = result.data.data.filter((m) => knownModuleNames.includes(m.name)); if (mods.length === 0) break; for (const mod of mods) { await client.module.moduleControllerRemove(mod.id);