From dedcbd087c1dcad058fe1b2f723c0679f1fad530 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 20:58:53 +0200 Subject: [PATCH 01/17] docs: design discord monitoring feedback fix --- ...discord-7d2d-monitoring-feedback-design.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md diff --git a/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md b/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md new file mode 100644 index 0000000..2fa4332 --- /dev/null +++ b/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md @@ -0,0 +1,48 @@ +# Discord 7D2D Monitoring Feedback Design + +## Goal + +Make `discord-7d2d-status-bridge` behave honestly and usefully in the user's real setup: monitoring delivery must either reach the configured Discord text channel or explain exactly why Discord rejected it, Blood Moon phase notices should be detected promptly, and an optional private Blood Moon notice should be sent when the first player joins. + +## Confirmed failure + +The user's `updateStatus` execution successfully reads players, server details, and `gettime`, then receives HTTP 403 from `POST /discord/channels/:id/message`. `bloodMoonMonitor` reports success during ordinary phases because it updates state without sending a Discord message. Existing integration tests use an empty monitoring channel and therefore exercise the skip path rather than outbound Discord delivery. + +The module cannot grant Discord permissions or make Takaro's bot join an inaccessible private or archived thread. It can preserve the channel-ID/Takaro-bot flow, expose a direct delivery check, and turn generic 403 failures into actionable diagnostics. Real acceptance still requires a regular Discord text channel that the Takaro bot can view and send to. + +## Design + +### Discord monitoring delivery + +- Keep `monitoringChannelId`; do not introduce webhooks. +- Add a `discordbridgecheck` command that sends a small test message to the monitoring channel and privately confirms success to the invoking player. +- Centralize Discord API error classification in the shared helper. +- For HTTP 403, log and throw an actionable error that names the channel and asks the operator to use a normal text channel and grant the Takaro bot `View Channel`, `Send Messages`, and `Read Message History`. Explicitly call out private or archived threads. +- Preserve other API errors and include their status/code without leaking credentials. +- Do not silently fall back to the chat channel, because that would break the requested channel separation. + +### Blood Moon timing + +- Change `bloodMoonMonitor` from every five minutes to every minute. The cron remains transition-based, so it sends at most once per phase and does not spam. +- Keep status-message refresh at five minutes to avoid unnecessary player/time/API traffic. +- Document that interval `1` means every in-game day; detection happens on the first one-minute cron tick after the configured in-game boundary. + +### Private first-player notice + +- Add `privateBloodMoonNoticeOnFirstJoin`, defaulting to `false` so existing installations do not gain unsolicited private messages. +- Add an optional `privateBloodMoonTodayMessage` override with English and Polish presets. +- On `player-connected`, reuse the status helper. If the option is enabled, today is a Blood Moon day, and the joining player is the only online player, send the resolved message with `player.pm`. +- Discord join monitoring and the private in-game notice remain independent: a Discord delivery failure must still fail visibly, while the private notice uses the game messaging path. + +## Testing + +- Extend the real Takaro integration suite first and observe failures before implementation. +- Replace empty-channel false positives for monitoring behavior with assertions that distinguish `skipped` from attempted delivery. +- Use a configured QA Discord channel for a real send when `TAKARO_DISCORD_TEST_CHANNEL_ID` is present; never fake the Takaro helper or replace source strings. +- Cover the actionable 403 diagnostic through a real execution log where the environment provides an inaccessible channel. +- Cover first-player private notice, non-Blood-Moon suppression, and second-player suppression through real Takaro hooks and event/game logs. +- Run TypeScript build, focused integration tests, export conversion, and mandatory Paper/bot verification. A live 7D2D run is required for precise `gettime`/Blood Moon claims; Paper proves only the game-agnostic join/private-message and Discord delivery paths. + +## Delivery boundary + +The module fix is complete only when source and real-Takaro tests pass and a real Discord POST succeeds in the QA channel. The affected user's channel can still require a one-time Discord configuration correction if Takaro receives 403 for that specific channel; the new command and error text will identify that directly. From 6a7320f9a7cf3d19ee515770c85da5db329630b4 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 20:59:58 +0200 Subject: [PATCH 02/17] docs: refine bridge feedback data flow --- .../2026-07-23-discord-7d2d-monitoring-feedback-design.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md b/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md index 2fa4332..ee0c14f 100644 --- a/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md +++ b/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback-design.md @@ -15,7 +15,6 @@ The module cannot grant Discord permissions or make Takaro's bot join an inacces ### Discord monitoring delivery - Keep `monitoringChannelId`; do not introduce webhooks. -- Add a `discordbridgecheck` command that sends a small test message to the monitoring channel and privately confirms success to the invoking player. - Centralize Discord API error classification in the shared helper. - For HTTP 403, log and throw an actionable error that names the channel and asks the operator to use a normal text channel and grant the Takaro bot `View Channel`, `Send Messages`, and `Read Message History`. Explicitly call out private or archived threads. - Preserve other API errors and include their status/code without leaking credentials. @@ -31,7 +30,7 @@ The module cannot grant Discord permissions or make Takaro's bot join an inacces - Add `privateBloodMoonNoticeOnFirstJoin`, defaulting to `false` so existing installations do not gain unsolicited private messages. - Add an optional `privateBloodMoonTodayMessage` override with English and Polish presets. -- On `player-connected`, reuse the status helper. If the option is enabled, today is a Blood Moon day, and the joining player is the only online player, send the resolved message with `player.pm`. +- The one-minute Blood Moon monitor remains the source of truth by persisting its current phase. On `player-connected`, read that phase and query only the online-player list. If the option is enabled, the phase is `today` or `start`, and the joining player is the only online player, send the resolved message with `player.pm`. - Discord join monitoring and the private in-game notice remain independent: a Discord delivery failure must still fail visibly, while the private notice uses the game messaging path. ## Testing From 293040ebff7cec1394d5f387d73f97eff9ab5e63 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:01:35 +0200 Subject: [PATCH 03/17] docs: plan discord monitoring feedback fix --- ...-07-23-discord-7d2d-monitoring-feedback.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/plans/2026-07-23-discord-7d2d-monitoring-feedback.md diff --git a/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback.md b/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback.md new file mode 100644 index 0000000..f3654aa --- /dev/null +++ b/docs/plans/2026-07-23-discord-7d2d-monitoring-feedback.md @@ -0,0 +1,211 @@ +# Discord 7D2D Monitoring Feedback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make Discord monitoring failures actionable, prove real outbound delivery, detect Blood Moon transitions every minute, and optionally privately notify the first joining player on a Blood Moon day. + +**Architecture:** Keep Takaro's channel-ID Discord API and add error normalization in the shared helper. Persist the Blood Moon phase from the one-minute monitor and let the join hook read that phase plus the online-player count, avoiding an extra `gettime` call on every join. All automated coverage continues to import/install/execute through the real Takaro API. + +**Tech Stack:** JavaScript Takaro functions, TypeScript Node test runner, `@takaro/apiclient`, Takaro mock game server, Paper bot service, Discord API through Takaro. + +--- + +### Task 1: Establish an honest focused baseline + +**Files:** +- Test: `modules/discord-7d2d-status-bridge/test/bridge.test.ts` + +**Step 1: Reuse repository-local runtime configuration** + +Link the main checkout's ignored `.env` and installed `node_modules` into the isolated worktree. Do not print credential values. + +**Step 2: Build the current branch** + +Run: `npm run build` + +Expected: TypeScript build succeeds. + +**Step 3: Run the current focused integration suite** + +Run: `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/discord-7d2d-status-bridge/test/bridge.test.ts` + +Expected: Existing tests pass or any infrastructure failure is recorded before feature work. + +### Task 2: Replace Discord skip-path false positives with real delivery evidence + +**Files:** +- Modify: `modules/discord-7d2d-status-bridge/test/bridge.test.ts` +- Modify: `modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js` + +**Step 1: Write the failing real-delivery assertions** + +Add an optional `TAKARO_DISCORD_TEST_CHANNEL_ID` test path that installs the disposable module with that monitoring channel, triggers `updateStatus`, and requires both: + +```ts +assertLogContains(logs, `/discord/channels/${channelId}/message 200 OK`); +assert.ok(!logs.some((message) => message.includes('skipped message:'))); +``` + +Add a failed-execution helper so a configured inaccessible channel can assert `success: false` and inspect logs rather than failing inside the test harness first. + +**Step 2: Run the focused test and verify RED against the reported channel** + +Run the focused command with `TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID` set from the user's reported execution context. + +Expected: FAIL because the current logs end in generic `403 Forbidden` without actionable channel guidance. + +**Step 3: Implement minimal Discord error normalization** + +Wrap the real API call in `sendDiscord` and derive `status`, Takaro error code, and Discord error code from the Axios response without logging tokens or headers. For 403, throw an error containing: + +```text +Discord channel is forbidden. Use a normal text channel and grant the Takaro bot View Channel, Send Messages, and Read Message History; private or archived threads may still reject the bot. +``` + +Preserve the original error as `cause`. Do not fall back to the chat channel. + +Apply the same normalization when updating the persistent message; if update fails, retain the existing replacement-send behavior and emit the normalized reason. + +**Step 4: Re-run and verify GREEN** + +Expected: the inaccessible-channel execution fails with the actionable diagnostic, and the configured QA channel records a real HTTP 200. + +**Step 5: Commit** + +```bash +git add modules/discord-7d2d-status-bridge/test/bridge.test.ts modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +git commit -m "fix: diagnose Discord monitoring delivery" +``` + +### Task 3: Detect Blood Moon phase transitions every minute + +**Files:** +- Modify: `modules/discord-7d2d-status-bridge/test/bridge.test.ts` +- Modify: `modules/discord-7d2d-status-bridge/module.json` + +**Step 1: Write the failing imported-module assertion** + +After importing the disposable module, find `bloodMoonMonitor` in `mod.latestVersion.cronJobs` and assert: + +```ts +assert.equal(bloodMoonCron.temporalValue, '* * * * *'); +``` + +Also assert `updateStatus` remains `*/5 * * * *`. + +**Step 2: Run and verify RED** + +Expected: FAIL with actual `*/5 * * * *` for `bloodMoonMonitor`. + +**Step 3: Implement the schedule change** + +Change only `bloodMoonMonitor.temporalValue` to `* * * * *` and update its description to state that phase transitions are checked every minute. Keep state-key duplicate suppression unchanged. + +**Step 4: Re-run and verify GREEN** + +Expected: schedule assertion passes and existing Blood Moon execution behavior remains green. + +**Step 5: Commit** + +```bash +git add modules/discord-7d2d-status-bridge/module.json modules/discord-7d2d-status-bridge/test/bridge.test.ts +git commit -m "fix: check blood moon phases every minute" +``` + +### Task 4: Privately notify the first joining player + +**Files:** +- Modify: `modules/discord-7d2d-status-bridge/test/bridge.test.ts` +- Modify: `modules/discord-7d2d-status-bridge/module.json` +- Modify: `modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js` +- Modify: `modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js` + +**Step 1: Write failing real-Takaro tests** + +Add config-schema assertions for: + +```json +"privateBloodMoonNoticeOnFirstJoin": { "type": "boolean", "default": false } +"privateBloodMoonTodayMessage": { "type": "string", "maxLength": 500, "default": "" } +``` + +Using a one-player disposable mock server and a real Takaro variable record with key `discord7d2d:bloodState` and JSON value `"today:7"`, trigger `player-connected` with the option enabled. Require a successful `/gameserver/:id/message` call and a diagnostic log containing the resolved private message. + +Add negative executions for: + +- option disabled; +- phase `normal:6`; +- more than one online player. + +Require no private-message diagnostic in all negative cases. + +**Step 2: Run and verify RED** + +Expected: schema fields and private notice logs are absent. + +**Step 3: Add config and localized messages** + +Add English preset `Blood Moon is today.` and Polish preset `Dzisiaj będzie Krwawy Księżyc.` under `privateBloodMoonTodayMessage`. Allow a non-empty user override via existing `resolveMessage` behavior. + +**Step 4: Implement the join data flow** + +In `monitorJoin`: + +1. Keep existing Discord join monitoring. +2. Return from private-notice logic unless the new option is true and `player` exists. +3. Read `BLOOD_STATE_KEY` through the real variable API. +4. Continue only for phase strings beginning `today:` or `start:`. +5. Call `getOnlinePlayers(gameServerId)` and continue only when `onlinePlayers === 1`. +6. Send `resolveMessage(config, 'privateBloodMoonTodayMessage')` with `player.pm`. +7. Log a stable success marker after the API call for integration evidence. + +The Discord send and private game send must be independent. A missing monitoring channel remains a skip, while a configured-but-forbidden Discord channel remains a visible failure. + +**Step 5: Run and verify GREEN** + +Expected: positive English, Polish/override, and all suppression cases pass through real Takaro executions. + +**Step 6: Commit** + +```bash +git add modules/discord-7d2d-status-bridge/module.json modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js modules/discord-7d2d-status-bridge/test/bridge.test.ts +git commit -m "feat: notify first player of blood moon day" +``` + +### Task 5: Verify export, Takaro, Discord, and game boundaries + +**Files:** +- Verify: `modules/discord-7d2d-status-bridge/**` + +**Step 1: Run static verification** + +```bash +npm run build +npm run typecheck +node --check modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +node --check modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js +node dist/scripts/module-to-json.js modules/discord-7d2d-status-bridge /tmp/discord-7d2d-status-bridge-feedback.json +git diff --check origin/main...HEAD +``` + +Expected: all commands succeed; the export retains the canonical name and a non-empty description. + +**Step 2: Run the complete focused integration suite** + +Run the Task 1 focused command with the designated QA Discord channel. + +Expected: all focused tests pass, a real Discord POST returns 200, and no outbound assertion is satisfied by `skipped message`. + +**Step 3: Run mandatory Paper/bot verification** + +Start `paper`, `bot`, and `redis`; use the actual `BOT_PORT` from `.env` without printing secrets. Install a disposable supported-games-neutral export, connect one bot, seed a real phase variable, trigger the join hook, and verify the private message through both the Takaro execution event and bot-visible game output. + +Expected: the bot receives the private Blood Moon message and the hook event is successful. State clearly that Paper does not prove 7D2D `gettime` parsing. + +**Step 4: Run 7D2D live proof when the active lane is reachable** + +Install the disposable module on the designated QA 7D2D server, trigger/observe a phase boundary, and verify the one-minute cron plus Discord delivery. Do not claim precise 7D2D acceptance without this evidence. + +**Step 5: Review and commit any verification-only documentation** + +Keep runtime artifacts and temporary exports outside the repository. Confirm `git status --short` contains only intended source/test/design files. From 30466dc802b4695f836ee39d15bb5fc00d0de604 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:07:35 +0200 Subject: [PATCH 04/17] test: isolate bridge integration resources --- modules/discord-7d2d-status-bridge/test/bridge.test.ts | 4 ++-- test/helpers/mock-server.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index 91889fd..684895b 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -26,7 +26,7 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); const MODULE_TO_JSON_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'dist', 'scripts', 'module-to-json.js'); -const TEST_MODULE_NAME = `test-discord-7d2d-status-bridge-${process.pid}`; +const TEST_MODULE_NAME = `qa-discord-7d2d-status-bridge-${process.pid}`; interface WaitForBridgeEventOptions { eventName: EventSearchInputAllowedFiltersEventNameEnum; @@ -197,7 +197,7 @@ describe('discord-7d2d-status-bridge integration', () => { client = await createClient(); await cleanupTestModules(client); await cleanupTestGameServers(client); - ctx = await startMockServer(client); + ctx = await startMockServer(client, { serverNamePrefix: 'qa-discord-bridge-' }); mod = await pushDisposableBridgeModule(client); playerNames = await Promise.all(ctx.players.map(async (playerOnGameserver) => { const player = await client.player.playerControllerGetOne(playerOnGameserver.playerId); diff --git a/test/helpers/mock-server.ts b/test/helpers/mock-server.ts index 73602c0..cf332a8 100644 --- a/test/helpers/mock-server.ts +++ b/test/helpers/mock-server.ts @@ -35,6 +35,7 @@ async function retry(fn: () => Promise, maxAttempts: number, delayMs: numb export interface StartMockServerOptions { totalPlayers?: number; + serverNamePrefix?: string; } export async function startMockServer( @@ -57,7 +58,7 @@ export async function startMockServer( mockserver: { registrationToken, identityToken, - name: `test-server-${identityToken}`, + name: `${options.serverNamePrefix ?? 'test-server-'}${identityToken}`, }, ws: { url: wsUrl, From f33509457b9bef8b7d3ffc47be91374945b2312e Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:17:02 +0200 Subject: [PATCH 05/17] fix: diagnose Discord monitoring delivery --- .../functions/discord-7d2d-status-helpers.js | 39 +++++++++++- .../test/bridge.test.ts | 60 ++++++++++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index ea9894d..b4a58f4 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -189,13 +189,47 @@ export function getDiscordChannelFromHook(data, configuredChannelId, hookName) { return data?.module?.systemConfig?.hooks?.[hookName]?.discordChannelId || data?.discordChannelId || data?.eventData?.discordChannelId || data?.eventData?.channelId || ''; } +function discordErrorStatus(err) { + const status = err?.response?.status ?? err?.status; + return Number.isInteger(status) ? status : null; +} + +function errorWithCause(message, cause) { + try { + return new Error(message, { cause }); + } catch (_err) { + return new Error(message); + } +} + +export function normalizeDiscordError(channelId, err) { + const status = discordErrorStatus(err); + const guidance = 'use a normal text channel and grant the Takaro bot View Channel, Send Messages, and Read Message History; private or archived threads may still reject the bot.'; + if (status === 403) { + return errorWithCause(`Discord delivery to channel ${channelId} was forbidden (HTTP 403): ${guidance}`, err); + } + if (status === 404) { + return errorWithCause(`Discord channel ${channelId} was not found or is unavailable to the Takaro bot (HTTP 404): ${guidance}`, err); + } + + const reason = err instanceof Error && err.message + ? err.message + : (typeof err === 'string' && err ? err : 'Unknown error'); + const statusText = status === null ? '' : ` (HTTP ${status})`; + return errorWithCause(`Discord delivery to channel ${channelId} failed${statusText}: ${reason}`, err); +} + export async function sendDiscord(channelId, message) { const safeMessage = sanitizeDiscordMessage(message); if (!channelId) { console.log(`discord-7d2d-status: no Discord channel configured, skipped message: ${safeMessage}`); return null; } - return takaro.discord.discordControllerSendMessage(channelId, { message: safeMessage }); + try { + return await takaro.discord.discordControllerSendMessage(channelId, { message: safeMessage }); + } catch (err) { + throw normalizeDiscordError(channelId, err); + } } export function sanitizeDiscordMessage(message) { @@ -231,7 +265,8 @@ export async function updatePersistentDiscordMessage(gameServerId, moduleId, cha await takaro.discord.discordControllerUpdateMessage(channelId, existingId, { message: safeMessage }); return existingId; } catch (err) { - console.error(`discord-7d2d-status: failed to update Discord status message ${existingId}, sending replacement: ${err}`); + const reason = normalizeDiscordError(channelId, err); + console.error(`discord-7d2d-status: failed to update Discord status message ${existingId}, sending replacement: ${reason.message}`); } } const sent = await sendDiscord(channelId, safeMessage); diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index 684895b..cdaef5f 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -27,6 +27,13 @@ const __dirname = path.dirname(__filename); const MODULE_DIR = path.resolve(__dirname, '..'); const MODULE_TO_JSON_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'dist', 'scripts', 'module-to-json.js'); const TEST_MODULE_NAME = `qa-discord-7d2d-status-bridge-${process.pid}`; +const DISCORD_TEST_CHANNEL_ID = process.env.TAKARO_DISCORD_TEST_CHANNEL_ID?.trim(); +const DISCORD_FORBIDDEN_CHANNEL_ID = process.env.TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID?.trim(); + +interface BridgeExecutionResult { + success: boolean; + logs: string[]; +} interface WaitForBridgeEventOptions { eventName: EventSearchInputAllowedFiltersEventNameEnum; @@ -153,7 +160,7 @@ describe('discord-7d2d-status-bridge integration', () => { return logs; } - async function triggerCronjob(name: string): Promise { + async function triggerCronjobExecution(name: string): Promise { const cronjob = mod.latestVersion.cronJobs.find((candidate) => candidate.name === name); assert.ok(cronjob, `Expected cronjob '${name}' to exist`); const beforeTrigger = new Date(); @@ -170,8 +177,13 @@ describe('discord-7d2d-status-bridge integration', () => { }); const result = (event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }).result; const logs = (result?.logs ?? []).map((entry) => entry.msg); - assert.equal(result?.success, true, `Expected cronjob '${name}' to succeed, logs: ${JSON.stringify(logs)}`); - return logs; + return { success: result?.success ?? false, logs }; + } + + async function triggerCronjob(name: string): Promise { + const execution = await triggerCronjobExecution(name); + assert.equal(execution.success, true, `Expected cronjob '${name}' to succeed, logs: ${JSON.stringify(execution.logs)}`); + return execution.logs; } async function waitForOnlineCount(expected: number) { @@ -351,6 +363,48 @@ describe('discord-7d2d-status-bridge integration', () => { assertLogContains(logs, `Players:\n${expectedPlayers.join('\n')}`); }); + it('delivers a status message through the real Takaro Discord API', { + skip: DISCORD_TEST_CHANNEL_ID ? false : 'Set TAKARO_DISCORD_TEST_CHANNEL_ID to run real Discord delivery coverage', + }, async () => { + assert.ok(DISCORD_TEST_CHANNEL_ID); + await installWithConfig({ + monitoringChannelId: DISCORD_TEST_CHANNEL_ID, + updateStatusMessage: false, + }); + + const execution = await triggerCronjobExecution('updateStatus'); + + assert.equal(execution.success, true, `Expected Discord delivery to succeed, logs: ${JSON.stringify(execution.logs)}`); + assert.ok( + !execution.logs.some((message) => message.includes('skipped message:')), + `A skipped message is not Discord delivery evidence: ${JSON.stringify(execution.logs)}`, + ); + assertLogContains(execution.logs, `/discord/channels/${DISCORD_TEST_CHANNEL_ID}/message 200 OK`); + }); + + it('explains how to fix a forbidden Discord monitoring channel', { + skip: DISCORD_FORBIDDEN_CHANNEL_ID ? false : 'Set TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID to run forbidden-channel coverage', + }, async () => { + assert.ok(DISCORD_FORBIDDEN_CHANNEL_ID); + await installWithConfig({ + monitoringChannelId: DISCORD_FORBIDDEN_CHANNEL_ID, + updateStatusMessage: false, + }); + + const execution = await triggerCronjobExecution('updateStatus'); + + assert.equal(execution.success, false, `Expected Discord delivery to fail, logs: ${JSON.stringify(execution.logs)}`); + const diagnostic = execution.logs.find((message) => ( + message.includes(DISCORD_FORBIDDEN_CHANNEL_ID) && message.includes('normal text channel') + )); + assert.ok(diagnostic, `Expected an actionable diagnostic naming the forbidden channel, logs: ${JSON.stringify(execution.logs)}`); + assert.match(diagnostic, /normal text channel/); + assert.match(diagnostic, /View Channel/); + assert.match(diagnostic, /Send Messages/); + assert.match(diagnostic, /Read Message History/); + assert.match(diagnostic, /private or archived threads may still reject the bot/); + }); + it('uses localized or overridden empty-player-list text', async () => { await ctx.server.executeConsoleCommand('disconnectAll'); await waitForOnlineCount(0); From 432d1371b227b900c4fb38ff34c5ba4a98e1bcb4 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:37:36 +0200 Subject: [PATCH 06/17] fix: harden Discord delivery failures --- .../functions/discord-7d2d-status-helpers.js | 61 ++++++++++++++--- .../test/bridge.test.ts | 67 +++++++++++++++++-- 2 files changed, 114 insertions(+), 14 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index b4a58f4..69abfcf 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -194,9 +194,44 @@ function discordErrorStatus(err) { return Number.isInteger(status) ? status : null; } -function errorWithCause(message, cause) { +function safeIdentifier(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value) ? value : null; +} + +function discordApiError(err) { + const value = err?.response?.data?.meta?.error; + return value && typeof value === 'object' ? value : null; +} + +function discordErrorCode(err) { + const apiError = discordApiError(err); + if (!apiError) return null; + for (const value of [apiError.code, apiError.message, apiError.details]) { + if (Number.isInteger(value) && value >= 1000) return value; + if (typeof value !== 'string') continue; + if (/^\d{4,6}$/.test(value)) return Number(value); + const match = value.match(/(?:DiscordAPIError|discord(?:\s+error)?\s+code|["']?code["']?)[^0-9]{0,16}(\d{4,6})/i); + if (match) return Number(match[1]); + } + return null; +} + +function safeDiscordCause(err) { + const details = []; + const status = discordErrorStatus(err); + const discordCode = discordErrorCode(err); + const takaroCode = safeIdentifier(discordApiError(err)?.code); + const requestCode = safeIdentifier(err?.code); + if (status !== null) details.push(`HTTP ${status}`); + if (discordCode !== null) details.push(`Discord code ${discordCode}`); + if (takaroCode && !/^\d+$/.test(takaroCode)) details.push(`Takaro code ${takaroCode}`); + if (requestCode) details.push(`request code ${requestCode}`); + return new Error(details.length > 0 ? `Discord request failed (${details.join(', ')})` : 'Discord request failed'); +} + +function errorWithSafeCause(message, err) { try { - return new Error(message, { cause }); + return new Error(message, { cause: safeDiscordCause(err) }); } catch (_err) { return new Error(message); } @@ -204,19 +239,24 @@ function errorWithCause(message, cause) { export function normalizeDiscordError(channelId, err) { const status = discordErrorStatus(err); - const guidance = 'use a normal text channel and grant the Takaro bot View Channel, Send Messages, and Read Message History; private or archived threads may still reject the bot.'; + const discordCode = discordErrorCode(err); + const codeText = discordCode === null ? '' : `, Discord code ${discordCode}`; + const guidance = 'verify the Discord guild is enabled and authorized in Takaro; use a normal text channel and grant the Takaro bot View Channel, Send Messages, and Read Message History; private or archived threads may still reject the bot.'; if (status === 403) { - return errorWithCause(`Discord delivery to channel ${channelId} was forbidden (HTTP 403): ${guidance}`, err); + return errorWithSafeCause(`Takaro or Discord refused delivery to channel ${channelId} (HTTP 403${codeText}): ${guidance}`, err); } if (status === 404) { - return errorWithCause(`Discord channel ${channelId} was not found or is unavailable to the Takaro bot (HTTP 404): ${guidance}`, err); + return errorWithSafeCause(`Discord channel ${channelId} was not found or is unavailable to the Takaro bot (HTTP 404${codeText}): ${guidance}`, err); } - const reason = err instanceof Error && err.message - ? err.message - : (typeof err === 'string' && err ? err : 'Unknown error'); const statusText = status === null ? '' : ` (HTTP ${status})`; - return errorWithCause(`Discord delivery to channel ${channelId} failed${statusText}: ${reason}`, err); + const requestCode = safeIdentifier(err?.code); + const reason = requestCode ? `: request code ${requestCode}` : ''; + return errorWithSafeCause(`Discord delivery to channel ${channelId} failed${statusText}${reason}`, err); +} + +function isMissingDiscordMessage(err) { + return discordErrorStatus(err) === 404 || discordErrorCode(err) === 10008; } export async function sendDiscord(channelId, message) { @@ -266,7 +306,8 @@ export async function updatePersistentDiscordMessage(gameServerId, moduleId, cha return existingId; } catch (err) { const reason = normalizeDiscordError(channelId, err); - console.error(`discord-7d2d-status: failed to update Discord status message ${existingId}, sending replacement: ${reason.message}`); + if (!isMissingDiscordMessage(err)) throw reason; + console.error(`discord-7d2d-status: prior Discord status message ${existingId} is missing, sending replacement: ${reason.message}`); } } const sent = await sendDiscord(channelId, safeMessage); diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index cdaef5f..4796086 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -29,6 +29,9 @@ const MODULE_TO_JSON_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'dist', const TEST_MODULE_NAME = `qa-discord-7d2d-status-bridge-${process.pid}`; const DISCORD_TEST_CHANNEL_ID = process.env.TAKARO_DISCORD_TEST_CHANNEL_ID?.trim(); const DISCORD_FORBIDDEN_CHANNEL_ID = process.env.TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID?.trim(); +if (DISCORD_TEST_CHANNEL_ID && DISCORD_FORBIDDEN_CHANNEL_ID && DISCORD_TEST_CHANNEL_ID === DISCORD_FORBIDDEN_CHANNEL_ID) { + throw new Error('Live Discord smoke gates require distinct accessible and forbidden channel IDs'); +} interface BridgeExecutionResult { success: boolean; @@ -110,6 +113,24 @@ async function pushDisposableBridgeModule(client: Client): Promise { let client: Client; let ctx: MockServerContext; @@ -174,6 +195,9 @@ describe('discord-7d2d-status-bridge integration', () => { gameserverId: ctx.gameServer.id, moduleId: mod.id, after: beforeTrigger, + predicate: (candidate) => ( + (candidate.meta as { cronjob?: { id?: string } }).cronjob?.id === cronjob.id + ), }); const result = (event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }).result; const logs = (result?.logs ?? []).map((entry) => entry.msg); @@ -363,8 +387,8 @@ describe('discord-7d2d-status-bridge integration', () => { assertLogContains(logs, `Players:\n${expectedPlayers.join('\n')}`); }); - it('delivers a status message through the real Takaro Discord API', { - skip: DISCORD_TEST_CHANNEL_ID ? false : 'Set TAKARO_DISCORD_TEST_CHANNEL_ID to run real Discord delivery coverage', + it('[live smoke] delivers a status message through the real Takaro Discord API', { + skip: DISCORD_TEST_CHANNEL_ID ? false : 'Live smoke gate skipped: set TAKARO_DISCORD_TEST_CHANNEL_ID', }, async () => { assert.ok(DISCORD_TEST_CHANNEL_ID); await installWithConfig({ @@ -382,8 +406,8 @@ describe('discord-7d2d-status-bridge integration', () => { assertLogContains(execution.logs, `/discord/channels/${DISCORD_TEST_CHANNEL_ID}/message 200 OK`); }); - it('explains how to fix a forbidden Discord monitoring channel', { - skip: DISCORD_FORBIDDEN_CHANNEL_ID ? false : 'Set TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID to run forbidden-channel coverage', + it('[live smoke] explains both Takaro and Discord authorization for a forbidden monitoring channel', { + skip: DISCORD_FORBIDDEN_CHANNEL_ID ? false : 'Live smoke gate skipped: set TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID', }, async () => { assert.ok(DISCORD_FORBIDDEN_CHANNEL_ID); await installWithConfig({ @@ -399,12 +423,47 @@ describe('discord-7d2d-status-bridge integration', () => { )); assert.ok(diagnostic, `Expected an actionable diagnostic naming the forbidden channel, logs: ${JSON.stringify(execution.logs)}`); assert.match(diagnostic, /normal text channel/); + assert.match(diagnostic, /Takaro or Discord/); + assert.match(diagnostic, /guild is enabled and authorized in Takaro/); assert.match(diagnostic, /View Channel/); assert.match(diagnostic, /Send Messages/); assert.match(diagnostic, /Read Message History/); assert.match(diagnostic, /private or archived threads may still reject the bot/); }); + it('[live smoke] does not replace a persistent status message when its update is forbidden', { + skip: DISCORD_FORBIDDEN_CHANNEL_ID ? false : 'Live smoke gate skipped: set TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID', + }, async () => { + assert.ok(DISCORD_FORBIDDEN_CHANNEL_ID); + const previousMessageId = '999999999999999999'; + await installWithConfig({ monitoringChannelId: DISCORD_FORBIDDEN_CHANNEL_ID }); + await setModuleVariable( + client, + ctx.gameServer.id, + mod.id, + `discord7d2d:statusMessage:${DISCORD_FORBIDDEN_CHANNEL_ID}`, + previousMessageId, + ); + + const execution = await triggerCronjobExecution('updateStatus'); + + assert.equal(execution.success, false, `Expected Discord update to fail, logs: ${JSON.stringify(execution.logs)}`); + assertLogContains( + execution.logs, + `/discord/channels/${DISCORD_FORBIDDEN_CHANNEL_ID}/messages/${previousMessageId}`, + ); + assert.equal( + execution.logs.filter((message) => ( + message.includes(`POST /discord/channels/${DISCORD_FORBIDDEN_CHANNEL_ID}/message`) + )).length, + 0, + `A forbidden update must not send a replacement message: ${JSON.stringify(execution.logs)}`, + ); + const diagnostic = execution.logs.find((message) => message.includes('Takaro or Discord')); + assert.ok(diagnostic, `Expected an authorization-layer diagnostic, logs: ${JSON.stringify(execution.logs)}`); + assert.match(diagnostic, /guild is enabled and authorized in Takaro/); + }); + it('uses localized or overridden empty-player-list text', async () => { await ctx.server.executeConsoleCommand('disconnectAll'); await waitForOnlineCount(0); From 53ce6fb218d65ba6de641457313fcb43935e10ec Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:46:32 +0200 Subject: [PATCH 07/17] fix: replace only unknown Discord messages --- .../functions/discord-7d2d-status-helpers.js | 12 +++-- .../test/bridge.test.ts | 44 ++++++++++++++++++- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index 69abfcf..64d85f7 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -242,11 +242,15 @@ export function normalizeDiscordError(channelId, err) { const discordCode = discordErrorCode(err); const codeText = discordCode === null ? '' : `, Discord code ${discordCode}`; const guidance = 'verify the Discord guild is enabled and authorized in Takaro; use a normal text channel and grant the Takaro bot View Channel, Send Messages, and Read Message History; private or archived threads may still reject the bot.'; + if (discordCode === 10008) { + const errorDetails = status === null ? 'Discord code 10008' : `HTTP ${status}, Discord code 10008`; + return errorWithSafeCause(`Discord reported Unknown Message for the prior status message in channel ${channelId} (${errorDetails}): the previous status message no longer exists.`, err); + } if (status === 403) { return errorWithSafeCause(`Takaro or Discord refused delivery to channel ${channelId} (HTTP 403${codeText}): ${guidance}`, err); } if (status === 404) { - return errorWithSafeCause(`Discord channel ${channelId} was not found or is unavailable to the Takaro bot (HTTP 404${codeText}): ${guidance}`, err); + return errorWithSafeCause(`Discord channel ${channelId} or its guild was not found or is unavailable to the Takaro bot (HTTP 404${codeText}): ${guidance}`, err); } const statusText = status === null ? '' : ` (HTTP ${status})`; @@ -255,8 +259,8 @@ export function normalizeDiscordError(channelId, err) { return errorWithSafeCause(`Discord delivery to channel ${channelId} failed${statusText}${reason}`, err); } -function isMissingDiscordMessage(err) { - return discordErrorStatus(err) === 404 || discordErrorCode(err) === 10008; +function shouldReplaceMissingMessage(err) { + return discordErrorCode(err) === 10008; } export async function sendDiscord(channelId, message) { @@ -306,7 +310,7 @@ export async function updatePersistentDiscordMessage(gameServerId, moduleId, cha return existingId; } catch (err) { const reason = normalizeDiscordError(channelId, err); - if (!isMissingDiscordMessage(err)) throw reason; + if (!shouldReplaceMissingMessage(err)) throw reason; console.error(`discord-7d2d-status: prior Discord status message ${existingId} is missing, sending replacement: ${reason.message}`); } } diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index 4796086..358af32 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -29,8 +29,11 @@ const MODULE_TO_JSON_SCRIPT = path.resolve(__dirname, '..', '..', '..', 'dist', const TEST_MODULE_NAME = `qa-discord-7d2d-status-bridge-${process.pid}`; const DISCORD_TEST_CHANNEL_ID = process.env.TAKARO_DISCORD_TEST_CHANNEL_ID?.trim(); const DISCORD_FORBIDDEN_CHANNEL_ID = process.env.TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID?.trim(); -if (DISCORD_TEST_CHANNEL_ID && DISCORD_FORBIDDEN_CHANNEL_ID && DISCORD_TEST_CHANNEL_ID === DISCORD_FORBIDDEN_CHANNEL_ID) { - throw new Error('Live Discord smoke gates require distinct accessible and forbidden channel IDs'); +const DISCORD_NOT_FOUND_CHANNEL_ID = process.env.TAKARO_DISCORD_NOT_FOUND_CHANNEL_ID?.trim(); +const LIVE_DISCORD_CHANNEL_IDS = [DISCORD_TEST_CHANNEL_ID, DISCORD_FORBIDDEN_CHANNEL_ID, DISCORD_NOT_FOUND_CHANNEL_ID] + .filter((channelId): channelId is string => Boolean(channelId)); +if (new Set(LIVE_DISCORD_CHANNEL_IDS).size !== LIVE_DISCORD_CHANNEL_IDS.length) { + throw new Error('Live Discord smoke gates require distinct channel IDs'); } interface BridgeExecutionResult { @@ -464,6 +467,43 @@ describe('discord-7d2d-status-bridge integration', () => { assert.match(diagnostic, /guild is enabled and authorized in Takaro/); }); + it('[live smoke] does not replace a persistent status message for a generic channel 404', { + skip: DISCORD_NOT_FOUND_CHANNEL_ID ? false : 'Live smoke gate skipped: set TAKARO_DISCORD_NOT_FOUND_CHANNEL_ID', + }, async () => { + assert.ok(DISCORD_NOT_FOUND_CHANNEL_ID); + const previousMessageId = '888888888888888888'; + await installWithConfig({ monitoringChannelId: DISCORD_NOT_FOUND_CHANNEL_ID }); + await setModuleVariable( + client, + ctx.gameServer.id, + mod.id, + `discord7d2d:statusMessage:${DISCORD_NOT_FOUND_CHANNEL_ID}`, + previousMessageId, + ); + + const execution = await triggerCronjobExecution('updateStatus'); + + assert.equal(execution.success, false, `Expected Discord update to fail, logs: ${JSON.stringify(execution.logs)}`); + assertLogContains(execution.logs, 'HTTP 404'); + assert.equal( + execution.logs.filter((message) => ( + message.includes(`POST /discord/channels/${DISCORD_NOT_FOUND_CHANNEL_ID}/message`) + )).length, + 0, + `A generic channel 404 must not send a replacement message: ${JSON.stringify(execution.logs)}`, + ); + assert.ok( + !execution.logs.some((message) => message.includes('prior Discord status message') && message.includes('is missing')), + `A generic channel 404 must not be described as a missing prior message: ${JSON.stringify(execution.logs)}`, + ); + const diagnostic = execution.logs.find((message) => ( + message.includes(`channel ${DISCORD_NOT_FOUND_CHANNEL_ID} or its guild`) + )); + assert.ok(diagnostic, `Expected a channel-or-guild 404 diagnostic, logs: ${JSON.stringify(execution.logs)}`); + assert.match(diagnostic, /not found or is unavailable/); + assert.doesNotMatch(diagnostic, /Unknown Message/); + }); + it('uses localized or overridden empty-player-list text', async () => { await ctx.server.executeConsoleCommand('disconnectAll'); await waitForOnlineCount(0); From 6a828be16f8178c14ef0dfb292a393ce745af616 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 21:51:57 +0200 Subject: [PATCH 08/17] fix: check blood moon phases every minute --- modules/discord-7d2d-status-bridge/module.json | 2 +- modules/discord-7d2d-status-bridge/test/bridge.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/discord-7d2d-status-bridge/module.json b/modules/discord-7d2d-status-bridge/module.json index af3ca2a..f69ea27 100644 --- a/modules/discord-7d2d-status-bridge/module.json +++ b/modules/discord-7d2d-status-bridge/module.json @@ -58,7 +58,7 @@ }, "cronJobs": { "updateStatus": { "temporalValue": "*/5 * * * *", "description": "Updates the persistent Discord server status message.", "function": "src/cronjobs/update-status/index.js" }, - "bloodMoonMonitor": { "temporalValue": "*/5 * * * *", "description": "Checks 7D2D time and sends blood moon day/start/end notices.", "function": "src/cronjobs/blood-moon-monitor/index.js" } + "bloodMoonMonitor": { "temporalValue": "* * * * *", "description": "Checks 7D2D phase transitions every minute and sends blood moon day/start/end notices.", "function": "src/cronjobs/blood-moon-monitor/index.js" } }, "functions": { "discord-7d2d-status-helpers": { "function": "src/functions/discord-7d2d-status-helpers.js" } diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index 358af32..b3f5cea 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -273,6 +273,14 @@ describe('discord-7d2d-status-bridge integration', () => { assert.ok(logs.length > 0); }); + it('checks blood moon transitions every minute without changing status updates', () => { + const bloodMoonMonitor = mod.latestVersion.cronJobs.find((cronjob) => cronjob.name === 'bloodMoonMonitor'); + const updateStatus = mod.latestVersion.cronJobs.find((cronjob) => cronjob.name === 'updateStatus'); + + assert.equal(bloodMoonMonitor?.temporalValue, '* * * * *'); + assert.equal(updateStatus?.temporalValue, '*/5 * * * *'); + }); + it('uses English monitoring messages by default', async () => { await installWithConfig(); From 498e405cdc45a4a4c7fed9dadb57ae460e75928e Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 22:01:56 +0200 Subject: [PATCH 09/17] perf: lighten blood moon phase polling --- .../src/cronjobs/blood-moon-monitor/index.js | 7 ++--- .../functions/discord-7d2d-status-helpers.js | 19 ++++++++----- .../test/bridge.test.ts | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js b/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js index 1d3e164..0d37ac7 100644 --- a/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js +++ b/modules/discord-7d2d-status-bridge/src/cronjobs/blood-moon-monitor/index.js @@ -1,5 +1,5 @@ import { data } from '@takaro/helpers'; -import { BLOOD_STATE_KEY, getDiscordChannelFromHook, getStatus, isBloodMoonDay, readVariable, resolveMessage, sendDiscord, writeVariable } from './discord-7d2d-status-helpers.js'; +import { BLOOD_STATE_KEY, getDiscordChannelFromHook, getTimeStatus, isBloodMoonDay, readVariable, resolveMessage, sendDiscord, writeVariable } from './discord-7d2d-status-helpers.js'; function phaseFor(status, config) { const parsed = status.parsed; @@ -19,10 +19,11 @@ async function main() { const { gameServerId, module: mod } = data; const config = mod.userConfig; const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'bloodMoonMonitor'); - const status = await getStatus(gameServerId, config); + const status = await getTimeStatus(gameServerId, config); const phase = phaseFor(status, config); const prev = await readVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, null); - if (phase.messageKey && prev !== phase.key) await sendDiscord(channelId, resolveMessage(config, phase.messageKey)); + if (prev === phase.key) return; + if (phase.messageKey) await sendDiscord(channelId, resolveMessage(config, phase.messageKey)); await writeVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, phase.key); } diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index 64d85f7..ed81876 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -134,18 +134,23 @@ export async function executeTimeCommand(gameServerId, command = 'gettime') { return JSON.stringify(data); } -export async function getStatus(gameServerId, config) { - const [players, serverName, rawTime] = await Promise.all([ - getOnlinePlayers(gameServerId), - getServerName(gameServerId), - executeTimeCommand(gameServerId, config.timeConsoleCommand ?? 'gettime'), - ]); +export async function getTimeStatus(gameServerId, config) { + const rawTime = await executeTimeCommand(gameServerId, config.timeConsoleCommand ?? 'gettime'); const parsed = parse7d2dTime(rawTime); if (!parsed) console.warn(`discord-7d2d-status: could not parse 7D2D time: ${rawTime}`); const day = parsed?.day ?? '?'; const time = parsed?.time ?? '?'; const active = isBloodMoonActive(parsed, config.firstHordeDay ?? 7, config.hordeIntervalDays ?? 7, config.bloodMoonStartHour ?? 22, config.bloodMoonEndHour ?? 4); - return { ...players, serverName, rawTime, parsed, day, time, bloodMoonActive: active }; + return { rawTime, parsed, day, time, bloodMoonActive: active }; +} + +export async function getStatus(gameServerId, config) { + const [players, serverName, timeStatus] = await Promise.all([ + getOnlinePlayers(gameServerId), + getServerName(gameServerId), + getTimeStatus(gameServerId, config), + ]); + return { ...players, serverName, ...timeStatus }; } export function renderTemplate(template, status, config) { diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index b3f5cea..4e1d39f 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -281,6 +281,33 @@ describe('discord-7d2d-status-bridge integration', () => { assert.equal(updateStatus?.temporalValue, '*/5 * * * *'); }); + it('polls blood moon time without loading players or server metadata', async () => { + const execution = await triggerCronjobExecution('bloodMoonMonitor'); + + assert.equal(execution.success, true, `Expected blood moon polling to succeed, logs: ${JSON.stringify(execution.logs)}`); + assert.ok( + !execution.logs.some((message) => message.includes('POST /gameserver/player/search')), + `Blood moon polling must not search players: ${JSON.stringify(execution.logs)}`, + ); + assert.ok( + !execution.logs.some((message) => message.includes(`GET /gameserver/${ctx.gameServer.id}`)), + `Blood moon polling must not fetch server metadata: ${JSON.stringify(execution.logs)}`, + ); + }); + + it('does not rewrite an unchanged blood moon phase', async () => { + await triggerCronjob('bloodMoonMonitor'); + const repeated = await triggerCronjobExecution('bloodMoonMonitor'); + + assert.equal(repeated.success, true, `Expected repeated blood moon polling to succeed, logs: ${JSON.stringify(repeated.logs)}`); + assert.ok( + !repeated.logs.some((message) => ( + /POST \/variables(?:\s|$)/.test(message) || message.includes('PUT /variables/') + )), + `An unchanged blood moon phase must not be written again: ${JSON.stringify(repeated.logs)}`, + ); + }); + it('uses English monitoring messages by default', async () => { await installWithConfig(); From 5d3c19d7e0bccd1b8a36ccc0b03c0bd0c4d8bda1 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 22:20:20 +0200 Subject: [PATCH 10/17] feat: notify first player of blood moon day --- .../discord-7d2d-status-bridge/module.json | 2 + .../functions/discord-7d2d-status-helpers.js | 2 + .../src/hooks/monitor-join/index.js | 28 ++- .../test/bridge.test.ts | 208 +++++++++++++++++- 4 files changed, 228 insertions(+), 12 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/module.json b/modules/discord-7d2d-status-bridge/module.json index f69ea27..ba4e113 100644 --- a/modules/discord-7d2d-status-bridge/module.json +++ b/modules/discord-7d2d-status-bridge/module.json @@ -15,6 +15,7 @@ "monitorJoins": { "type": "boolean", "default": true }, "monitorLeaves": { "type": "boolean", "default": true }, "monitorDeaths": { "type": "boolean", "default": true }, + "privateBloodMoonNoticeOnFirstJoin": { "type": "boolean", "default": false, "description": "Privately notify the first player online when the persisted Blood Moon phase is today or starting." }, "joinMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional join-message override. Placeholder: {player}." }, "leaveMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional leave-message override. Placeholder: {player}." }, "deathMessageTemplate": { "type": "string", "maxLength": 500, "default": "", "description": "Optional death-message override. Placeholder: {player}." }, @@ -32,6 +33,7 @@ "bloodMoonStartHour": { "type": "integer", "minimum": 0, "maximum": 23, "default": 22 }, "bloodMoonEndHour": { "type": "integer", "minimum": 0, "maximum": 23, "default": 4 }, "bloodMoonTodayMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional blood-moon-day message override." }, + "privateBloodMoonTodayMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional private first-player Blood Moon notice override. The selected language preset is used when empty." }, "bloodMoonStartingMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional blood-moon-starting message override." }, "bloodMoonEndingMessage": { "type": "string", "maxLength": 500, "default": "", "description": "Optional blood-moon-ending message override." } }, diff --git a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js index ed81876..9b20b7e 100644 --- a/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js +++ b/modules/discord-7d2d-status-bridge/src/functions/discord-7d2d-status-helpers.js @@ -10,6 +10,7 @@ export const MESSAGE_PRESETS = { deathMessageTemplate: '{player} died.', deathWithReasonMessageTemplate: '{player} died: {reason}', bloodMoonTodayMessage: 'Today is the Blood Moon day...', + privateBloodMoonTodayMessage: 'Today is the Blood Moon day...', bloodMoonStartingMessage: 'Blood Moon is starting...', bloodMoonEndingMessage: 'Blood Moon is ending...', serverStartingMessageTemplate: 'Server starting...', @@ -24,6 +25,7 @@ export const MESSAGE_PRESETS = { deathMessageTemplate: '{player} nie żyje.', deathWithReasonMessageTemplate: '{player} nie żyje: {reason}', bloodMoonTodayMessage: 'Dzisiaj zapowiadają Krwawy Księżyc...', + privateBloodMoonTodayMessage: 'Dzisiaj zapowiadają Krwawy Księżyc...', bloodMoonStartingMessage: 'Krwawy Księżyc wschodzi...', bloodMoonEndingMessage: 'Krwawy Księżyc zachodzi...', serverStartingMessageTemplate: 'Serwer startuje...', diff --git a/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js b/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js index 73f9ead..391a14c 100644 --- a/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js +++ b/modules/discord-7d2d-status-bridge/src/hooks/monitor-join/index.js @@ -1,9 +1,33 @@ import { data } from '@takaro/helpers'; -import { getDiscordChannelFromHook, renderMessage, resolveMessage, sendDiscord } from './discord-7d2d-status-helpers.js'; +import { + BLOOD_STATE_KEY, + getDiscordChannelFromHook, + getOnlinePlayers, + readVariable, + renderMessage, + resolveMessage, + sendDiscord, +} from './discord-7d2d-status-helpers.js'; + +async function sendPrivateBloodMoonNotice(gameServerId, mod, player) { + const config = mod.userConfig; + if (config.privateBloodMoonNoticeOnFirstJoin !== true || !player) return; + + const phase = await readVariable(gameServerId, mod.moduleId, BLOOD_STATE_KEY, null); + if (typeof phase !== 'string' || (!phase.startsWith('today:') && !phase.startsWith('start:'))) return; + + const { onlinePlayers } = await getOnlinePlayers(gameServerId); + if (onlinePlayers !== 1) return; + + const message = resolveMessage(config, 'privateBloodMoonTodayMessage'); + await player.pm(message); + console.log(`discord-7d2d-status: private blood moon first-join notice sent: ${message}`); +} async function main() { - const { player, module: mod } = data; + const { gameServerId, player, module: mod } = data; const config = mod.userConfig; + await sendPrivateBloodMoonNotice(gameServerId, mod, player); if (config.monitorJoins === false) return; const channelId = getDiscordChannelFromHook(data, config.monitoringChannelId, 'monitorJoin'); const playerName = player?.name || resolveMessage(config, 'unknownPlayerText'); diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index 4e1d39f..1198798 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -137,8 +137,10 @@ async function setModuleVariable( describe('discord-7d2d-status-bridge integration', () => { let client: Client; let ctx: MockServerContext; + let noticeCtx: MockServerContext; let mod: ModuleOutputDTO; let installed = false; + let noticeInstalled = false; let playerName: string; let playerNames: string[]; @@ -154,13 +156,33 @@ describe('discord-7d2d-status-bridge integration', () => { installed = true; } - async function triggerHook( + async function installNoticeWithConfig(userConfig: Record = {}) { + if (noticeInstalled) { + await uninstallModule(client, mod.id, noticeCtx.gameServer.id); + noticeInstalled = false; + } + await installModule(client, mod.latestVersion.id, noticeCtx.gameServer.id, { + userConfig: { chatChannelId: '', monitoringChannelId: '', ...userConfig }, + systemConfig: { hooks: { discordChatRelay: { discordChannelId: '1' } } }, + }); + noticeInstalled = true; + } + + async function triggerHookExecution( eventType: HookTriggerDTOEventTypeEnum, - options: { playerId?: string; eventMeta?: Record; expectedLog?: string } = {}, - ): Promise { + options: { + playerId?: string; + eventMeta?: Record; + expectedLog?: string; + serverContext?: MockServerContext; + } = {}, + ): Promise { + const hook = mod.latestVersion.hooks.find((candidate) => candidate.eventType === eventType); + assert.ok(hook, `Expected hook for '${eventType}' to exist`); + const target = options.serverContext ?? ctx; const beforeTrigger = new Date(); await client.hook.hookControllerTrigger({ - gameServerId: ctx.gameServer.id, + gameServerId: target.gameServer.id, moduleId: mod.id, playerId: options.playerId, eventType, @@ -168,20 +190,36 @@ describe('discord-7d2d-status-bridge integration', () => { }); const event = await waitForBridgeEvent(client, { eventName: EventSearchInputAllowedFiltersEventNameEnum.HookExecuted, - gameserverId: ctx.gameServer.id, + gameserverId: target.gameServer.id, moduleId: mod.id, after: beforeTrigger, - predicate: options.expectedLog - ? (candidate) => { + predicate: (candidate) => { + const meta = candidate.meta as { hook?: { id?: string }; result?: { logs?: Array<{ msg: string }> } }; + if (meta.hook?.id !== hook.id) return false; + if (options.expectedLog) { const result = (candidate.meta as { result?: { logs?: Array<{ msg: string }> } }).result; return (result?.logs ?? []).some((entry) => entry.msg.includes(options.expectedLog!)); } - : undefined, + return true; + }, }); const result = (event.meta as { result?: { success?: boolean; logs?: Array<{ msg: string }> } }).result; const logs = (result?.logs ?? []).map((entry) => entry.msg); - assert.equal(result?.success, true, `Expected hook '${eventType}' to succeed, logs: ${JSON.stringify(logs)}`); - return logs; + return { success: result?.success ?? false, logs }; + } + + async function triggerHook( + eventType: HookTriggerDTOEventTypeEnum, + options: { + playerId?: string; + eventMeta?: Record; + expectedLog?: string; + serverContext?: MockServerContext; + } = {}, + ): Promise { + const execution = await triggerHookExecution(eventType, options); + assert.equal(execution.success, true, `Expected hook '${eventType}' to succeed, logs: ${JSON.stringify(execution.logs)}`); + return execution.logs; } async function triggerCronjobExecution(name: string): Promise { @@ -237,6 +275,7 @@ describe('discord-7d2d-status-bridge integration', () => { await cleanupTestModules(client); await cleanupTestGameServers(client); ctx = await startMockServer(client, { serverNamePrefix: 'qa-discord-bridge-' }); + noticeCtx = await startMockServer(client, { serverNamePrefix: 'qa-discord-notice-', totalPlayers: 1 }); mod = await pushDisposableBridgeModule(client); playerNames = await Promise.all(ctx.players.map(async (playerOnGameserver) => { const player = await client.player.playerControllerGetOne(playerOnGameserver.playerId); @@ -253,6 +292,12 @@ describe('discord-7d2d-status-bridge integration', () => { after(async () => { if (mod && ctx) { + if (noticeInstalled && noticeCtx) { + await uninstallModule(client, mod.id, noticeCtx.gameServer.id).catch((err) => { + const status = (err as { response?: { status?: number } }).response?.status; + console.error(`Cleanup: failed to uninstall bridge notice test module (HTTP ${status ?? 'unknown'})`); + }); + } if (installed) { await uninstallModule(client, mod.id, ctx.gameServer.id).catch((err) => { const status = (err as { response?: { status?: number } }).response?.status; @@ -265,6 +310,7 @@ describe('discord-7d2d-status-bridge integration', () => { }); } if (ctx) await stopMockServer(ctx.server, client, ctx.gameServer.id); + if (noticeCtx) await stopMockServer(noticeCtx.server, client, noticeCtx.gameServer.id); }); it('imports under a disposable name and scopes hook events to that module', async () => { @@ -281,6 +327,148 @@ describe('discord-7d2d-status-bridge integration', () => { assert.equal(updateStatus?.temporalValue, '*/5 * * * *'); }); + it('imports the opt-in private Blood Moon notice config with safe defaults', () => { + const configSchema = JSON.parse(mod.latestVersion.configSchema) as { + properties?: Record; + }; + const enabled = configSchema.properties?.privateBloodMoonNoticeOnFirstJoin; + const message = configSchema.properties?.privateBloodMoonTodayMessage; + + assert.deepEqual( + { type: enabled?.type, default: enabled?.default }, + { type: 'boolean', default: false }, + ); + assert.ok(enabled?.description, 'Expected the private notice toggle to have a description'); + assert.deepEqual( + { type: message?.type, maxLength: message?.maxLength, default: message?.default }, + { type: 'string', maxLength: 500, default: '' }, + ); + assert.ok(message?.description, 'Expected the private notice message to have a description'); + }); + + it('privately sends the exact English Blood Moon Today wording to the first online player', async () => { + await installNoticeWithConfig({ + monitorJoins: false, + privateBloodMoonNoticeOnFirstJoin: true, + }); + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'today:7'); + + const logs = await triggerHook('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assertLogContains(logs, `/gameserver/${noticeCtx.gameServer.id}/message 200 OK`); + assertLogContains(logs, 'private blood moon first-join notice sent: Today is the Blood Moon day...'); + assert.ok( + !logs.some((message) => message.includes(`/gameserver/${noticeCtx.gameServer.id}/command`)), + `Join notice must use persisted phase without executing gettime: ${JSON.stringify(logs)}`, + ); + }); + + it('uses the exact Polish Blood Moon Today wording for a persisted start phase', async () => { + await installNoticeWithConfig({ language: 'pl', privateBloodMoonNoticeOnFirstJoin: true }); + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'start:7'); + + const logs = await triggerHook('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assertLogContains(logs, `/gameserver/${noticeCtx.gameServer.id}/message 200 OK`); + assertLogContains(logs, 'private blood moon first-join notice sent: Dzisiaj zapowiadają Krwawy Księżyc...'); + }); + + it('supports a non-empty private Blood Moon message override', async () => { + await installNoticeWithConfig({ + privateBloodMoonNoticeOnFirstJoin: true, + privateBloodMoonTodayMessage: 'Custom private Blood Moon warning', + }); + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'today:7'); + + const logs = await triggerHook('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assertLogContains(logs, 'private blood moon first-join notice sent: Custom private Blood Moon warning'); + }); + + it('does not privately notify when the option is disabled by default', async () => { + await installNoticeWithConfig(); + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'today:7'); + + const logs = await triggerHook('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assert.ok(!logs.some((message) => message.includes('private blood moon first-join notice sent:'))); + assert.ok(!logs.some((message) => message.includes(`/gameserver/${noticeCtx.gameServer.id}/message`))); + }); + + it('does not privately notify for missing, normal, or ended Blood Moon state', async () => { + await installNoticeWithConfig({ privateBloodMoonNoticeOnFirstJoin: true }); + for (const phase of [null, 'normal:6', 'end:7']) { + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', phase); + + const logs = await triggerHook('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assert.ok( + !logs.some((message) => message.includes('private blood moon first-join notice sent:')), + `Phase ${JSON.stringify(phase)} must not send a private notice: ${JSON.stringify(logs)}`, + ); + assert.ok( + !logs.some((message) => message.includes(`/gameserver/${noticeCtx.gameServer.id}/message`)), + `Phase ${JSON.stringify(phase)} must not call the game message API: ${JSON.stringify(logs)}`, + ); + } + }); + + it('does not privately notify a missing player', async () => { + await installNoticeWithConfig({ privateBloodMoonNoticeOnFirstJoin: true }); + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'today:7'); + + const logs = await triggerHook('player-connected', { serverContext: noticeCtx }); + + assert.ok(!logs.some((message) => message.includes('private blood moon first-join notice sent:'))); + assert.ok(!logs.some((message) => message.includes(`/gameserver/${noticeCtx.gameServer.id}/message`))); + }); + + it('does not privately notify when more than one player is online', async () => { + await installWithConfig({ privateBloodMoonNoticeOnFirstJoin: true }); + await setModuleVariable(client, ctx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'today:7'); + + const logs = await triggerHook('player-connected', { playerId: ctx.players[0].playerId }); + + assert.ok(!logs.some((message) => message.includes('private blood moon first-join notice sent:'))); + assert.ok(!logs.some((message) => message.includes(`/gameserver/${ctx.gameServer.id}/message`))); + }); + + it('[live smoke] delivers the private notice before a configured Discord join failure remains visible', { + skip: DISCORD_FORBIDDEN_CHANNEL_ID ? false : 'Live smoke gate skipped: set TAKARO_DISCORD_FORBIDDEN_CHANNEL_ID', + }, async () => { + assert.ok(DISCORD_FORBIDDEN_CHANNEL_ID); + await installNoticeWithConfig({ + monitoringChannelId: DISCORD_FORBIDDEN_CHANNEL_ID, + privateBloodMoonNoticeOnFirstJoin: true, + }); + await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', 'today:7'); + + const execution = await triggerHookExecution('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assert.equal(execution.success, false, `Configured Discord failure must fail the hook: ${JSON.stringify(execution.logs)}`); + assertLogContains(execution.logs, `/gameserver/${noticeCtx.gameServer.id}/message 200 OK`); + assertLogContains(execution.logs, 'private blood moon first-join notice sent: Today is the Blood Moon day...'); + assertLogContains(execution.logs, `Takaro or Discord refused delivery to channel ${DISCORD_FORBIDDEN_CHANNEL_ID}`); + }); + it('polls blood moon time without loading players or server metadata', async () => { const execution = await triggerCronjobExecution('bloodMoonMonitor'); From 98a2abbc95cf3ebc34ab259798b4b005e3b2107b Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 22:37:51 +0200 Subject: [PATCH 11/17] test: cover missing blood moon state --- .../test/bridge.test.ts | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/modules/discord-7d2d-status-bridge/test/bridge.test.ts b/modules/discord-7d2d-status-bridge/test/bridge.test.ts index 1198798..5c50c1a 100644 --- a/modules/discord-7d2d-status-bridge/test/bridge.test.ts +++ b/modules/discord-7d2d-status-bridge/test/bridge.test.ts @@ -134,6 +134,20 @@ async function setModuleVariable( } } +async function deleteModuleVariable( + client: Client, + gameServerId: string, + moduleId: string, + key: string, +) { + const existing = await client.variable.variableControllerSearch({ + filters: { key: [key], gameServerId: [gameServerId], moduleId: [moduleId] }, + }); + await Promise.all( + existing.data.data.map((variable) => client.variable.variableControllerDelete(variable.id)), + ); +} + describe('discord-7d2d-status-bridge integration', () => { let client: Client; let ctx: MockServerContext; @@ -407,9 +421,30 @@ describe('discord-7d2d-status-bridge integration', () => { assert.ok(!logs.some((message) => message.includes(`/gameserver/${noticeCtx.gameServer.id}/message`))); }); - it('does not privately notify for missing, normal, or ended Blood Moon state', async () => { + it('does not privately notify when the Blood Moon state variable is absent', async () => { + await installNoticeWithConfig({ privateBloodMoonNoticeOnFirstJoin: true }); + await deleteModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState'); + const variables = await client.variable.variableControllerSearch({ + filters: { + key: ['discord7d2d:bloodState'], + gameServerId: [noticeCtx.gameServer.id], + moduleId: [mod.id], + }, + }); + assert.equal(variables.data.data.length, 0, 'Expected no persisted Blood Moon state record'); + + const logs = await triggerHook('player-connected', { + playerId: noticeCtx.players[0].playerId, + serverContext: noticeCtx, + }); + + assert.ok(!logs.some((message) => message.includes('private blood moon first-join notice sent:'))); + assert.ok(!logs.some((message) => message.includes(`/gameserver/${noticeCtx.gameServer.id}/message`))); + }); + + it('does not privately notify for normal or ended Blood Moon state', async () => { await installNoticeWithConfig({ privateBloodMoonNoticeOnFirstJoin: true }); - for (const phase of [null, 'normal:6', 'end:7']) { + for (const phase of ['normal:6', 'end:7']) { await setModuleVariable(client, noticeCtx.gameServer.id, mod.id, 'discord7d2d:bloodState', phase); const logs = await triggerHook('player-connected', { From 9ad7081a4344d8d0703ca19c2a440bba87eb25dc Mon Sep 17 00:00:00 2001 From: El-Limon Date: Thu, 23 Jul 2026 22:48:14 +0200 Subject: [PATCH 12/17] docs: design blood moon delivery state --- ...6-07-23-discord-7d2d-blood-state-design.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/plans/2026-07-23-discord-7d2d-blood-state-design.md diff --git a/docs/plans/2026-07-23-discord-7d2d-blood-state-design.md b/docs/plans/2026-07-23-discord-7d2d-blood-state-design.md new file mode 100644 index 0000000..5cb12cc --- /dev/null +++ b/docs/plans/2026-07-23-discord-7d2d-blood-state-design.md @@ -0,0 +1,32 @@ +# Discord 7D2D Blood State Design + +## Goal + +Keep the private first-player Blood Moon notice current even when Discord delivery fails, while retrying failed Discord announcements without duplicating successful ones. + +## State model + +- `discord7d2d:bloodState` stores only the currently observed phase used by the join hook. +- `discord7d2d:bloodDelivered` stores up to 32 unique announcement keys that Discord has accepted, or that the existing empty-channel skip path has completed. +- Announcement keys remain `today:`, `start:`, and `end:` so legacy `bloodState` values can migrate without translation. + +Each cron execution persists the observed phase before attempting Discord. It then migrates any announcement-shaped legacy `bloodState` value into delivered history, derives pending announcements, and processes them in order. Each successful announcement is recorded immediately. A failed send is not recorded, so the next cron retries it without repeating earlier successes. + +## Phase and announcement derivation + +Observed state and announcements are separate outputs. For ordinary intervals, behavior remains today, start, and end at the existing boundaries. When consecutive days are Blood Moon days, the previous Blood Moon remains active until the configured end hour. At or after that boundary, the current observed state becomes `today:` and the cron can expose both `end:` and `today:` as ordered announcements. + +For interval `1`, day 8 at 05:00 therefore produces: + +- observed state: `today:8`; +- announcements: `end:7`, then `today:8`. + +## Migration + +When the delivered-history variable does not exist, any legacy announcement-shaped `bloodState` value is recorded as delivered before current pending work is compared. The old implementation wrote that state only after successful or skipped delivery, so this prevents a current announcement from repeating immediately after upgrade. Normal and unknown legacy states do not imply a delivered announcement. + +## Integration proof + +The active WebSocket mock server has no configurable `gettime` response. The disposable test import will extend only its config schema to permit deterministic `say Day