From 3087a41c0069779dda9dd49bbac3238302621873 Mon Sep 17 00:00:00 2001 From: Harambe Date: Sat, 4 Jul 2026 18:06:53 +0000 Subject: [PATCH 1/2] feat: add blood moon countdown module --- modules/blood-moon-countdown/module.json | 121 ++++++++++++++ .../src/commands/bloodmoon/index.js | 22 +++ .../src/functions/blood-moon-helpers.d.ts | 15 ++ .../src/functions/blood-moon-helpers.js | 151 ++++++++++++++++++ .../src/hooks/discord-bloodmoon/index.js | 48 ++++++ .../test/blood-moon-countdown.test.ts | 50 ++++++ 6 files changed, 407 insertions(+) create mode 100644 modules/blood-moon-countdown/module.json create mode 100644 modules/blood-moon-countdown/src/commands/bloodmoon/index.js create mode 100644 modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts create mode 100644 modules/blood-moon-countdown/src/functions/blood-moon-helpers.js create mode 100644 modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js create mode 100644 modules/blood-moon-countdown/test/blood-moon-countdown.test.ts diff --git a/modules/blood-moon-countdown/module.json b/modules/blood-moon-countdown/module.json new file mode 100644 index 0000000..975003d --- /dev/null +++ b/modules/blood-moon-countdown/module.json @@ -0,0 +1,121 @@ +{ + "name": "blood-moon-countdown", + "author": "Takaro", + "description": "7 Days to Die helper commands and optional Discord replies that tell players how many in-game days remain until the next blood moon/horde night. The interval, first horde day, console command, manual fallback day, Discord triggers, and player-facing messages are editable in module config.", + "version": "latest", + "supportedGames": [ + "all" + ], + "config": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "hordeIntervalDays": { + "type": "integer", + "minimum": 1, + "default": 7, + "description": "How often blood moon/horde night happens, in in-game days. Vanilla is every 7 days." + }, + "firstHordeDay": { + "type": "integer", + "minimum": 1, + "default": 7, + "description": "The first blood moon day in the cycle. Vanilla is day 7." + }, + "timeConsoleCommand": { + "type": "string", + "default": "gettime", + "maxLength": 80, + "description": "Console command used to read current 7D2D game time. The output must include the current day, such as 'Day 12'." + }, + "manualCurrentDay": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Manual current day used when parseConsoleTime is disabled, or as fallback when allowManualFallbackOnParseFailure is enabled." + }, + "allowManualFallbackOnParseFailure": { + "type": "boolean", + "default": false, + "description": "When enabled, use manualCurrentDay if the console command fails or cannot be parsed. Leave disabled to avoid stale countdowns when live time lookup breaks." + }, + "parseConsoleTime": { + "type": "boolean", + "default": true, + "description": "When enabled, /bloodmoon and /day7 run the console command and parse the current day. Disable to use manualCurrentDay only." + }, + "responseTemplate": { + "type": "string", + "default": "Blood moon: Day {nextHordeDay}. Current day: {currentDay}. {daysUntilText}.", + "maxLength": 300, + "description": "Player response template. Supports {currentDay}, {nextHordeDay}, {daysUntil}, {daysUntilText}, {interval}, {firstHordeDay}, and {source}." + }, + "hordeTodayText": { + "type": "string", + "default": "Blood moon is tonight", + "maxLength": 120, + "description": "Text used for {daysUntilText} when the next horde day is today." + }, + "daysUntilText": { + "type": "string", + "default": "{daysUntil} day(s) remaining", + "maxLength": 120, + "description": "Text used for {daysUntilText} when the horde is in the future. Supports {daysUntil}." + }, + "parseErrorMessage": { + "type": "string", + "default": "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config or set manualCurrentDay.", + "maxLength": 300, + "description": "Player-facing message when console time parsing fails and no manual fallback can be used." + }, + "includeConsoleOutputInLogs": { + "type": "boolean", + "default": true, + "description": "Log the raw console output when parsing fails. Useful while tuning timeConsoleCommand." + }, + "discordTriggers": { + "type": "array", + "default": ["!bloodmoon", "!day7"], + "description": "Discord message triggers that make the module reply in Discord. Requires the discordBloodmoon hook to be configured with a Discord channel.", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 40 + } + } + }, + "required": [], + "additionalProperties": false + }, + "uiSchema": {}, + "permissions": [], + "commands": { + "bloodmoon": { + "trigger": "bloodmoon", + "description": "Show how many in-game days remain until the next blood moon/horde night.", + "helpText": "Usage: /bloodmoon — shows current day, next horde day, and days remaining.", + "function": "src/commands/bloodmoon/index.js", + "arguments": [] + }, + "day7": { + "trigger": "day7", + "description": "Alias for /bloodmoon.", + "helpText": "Usage: /day7 — shows how many days remain until the next blood moon/horde night.", + "function": "src/commands/bloodmoon/index.js", + "arguments": [] + } + }, + "hooks": { + "discordBloodmoon": { + "eventType": "discord-message", + "description": "Reply in Discord when someone types a configured trigger such as !bloodmoon or !day7.", + "function": "src/hooks/discord-bloodmoon/index.js" + } + }, + "cronJobs": {}, + "functions": { + "blood-moon-helpers": { + "function": "src/functions/blood-moon-helpers.js" + } + } +} diff --git a/modules/blood-moon-countdown/src/commands/bloodmoon/index.js b/modules/blood-moon-countdown/src/commands/bloodmoon/index.js new file mode 100644 index 0000000..d2cf45e --- /dev/null +++ b/modules/blood-moon-countdown/src/commands/bloodmoon/index.js @@ -0,0 +1,22 @@ +import { data, takaro, TakaroUserError } from '@takaro/helpers'; +import { buildBloodMoonResponse } from './blood-moon-helpers.js'; + +async function main() { + const { gameServerId, pog, module: mod } = data; + const config = mod.userConfig ?? {}; + + const result = await buildBloodMoonResponse(gameServerId, config, takaro); + if (!result) { + throw new TakaroUserError( + config.parseErrorMessage || + "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config or set manualCurrentDay.", + ); + } + + console.log( + `blood-moon-countdown: currentDay=${result.currentDay}, nextHordeDay=${result.nextHordeDay}, daysUntil=${result.daysUntil}, source=${result.source}`, + ); + await pog.pm(result.message); +} + +await main(); diff --git a/modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts new file mode 100644 index 0000000..34c654e --- /dev/null +++ b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts @@ -0,0 +1,15 @@ +export function toPositiveInteger(value: unknown, fallback: number | null): number | null; +export function extractCurrentDay(rawOutput: unknown): number | null; +export function calculateNextHordeDay(currentDay: unknown, interval: unknown, firstHordeDay: unknown): number; +export function renderBloodMoonMessage(template: unknown, values: Record): string; +export function buildDaysUntilText(config: Record, daysUntil: number): string; +export function resolveCurrentDay( + gameServerId: string, + config: Record, + takaro: unknown, +): Promise<{ currentDay: number; source: string } | null>; +export function buildBloodMoonResponse( + gameServerId: string, + config: Record, + takaro: unknown, +): Promise<{ message: string; currentDay: number; nextHordeDay: number; daysUntil: number; source: string } | null>; diff --git a/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js new file mode 100644 index 0000000..2eb0a18 --- /dev/null +++ b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js @@ -0,0 +1,151 @@ +export function toPositiveInteger(value, fallback) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (Number.isFinite(parsed) && parsed >= 1) return parsed; + return fallback; +} + +export function extractCurrentDay(rawOutput) { + const text = String(rawOutput ?? '').replace(/\r/g, '\n'); + + const patterns = [ + /\bday\s*[:#-]?\s*(\d+)\b/i, + /\bd\s*[:#-]?\s*(\d+)\b/i, + /\bgame\s+time\b[^\d]*(\d+)\b/i, + /\bworld\s+time\b[^\d]*(\d+)\b/i, + ]; + + for (const pattern of patterns) { + const match = text.match(pattern); + if (match) { + const day = Number.parseInt(match[1], 10); + if (Number.isFinite(day) && day >= 1) return day; + } + } + + return null; +} + +export function calculateNextHordeDay(currentDay, interval, firstHordeDay) { + const safeCurrentDay = toPositiveInteger(currentDay, 1); + const safeInterval = toPositiveInteger(interval, 7); + const safeFirstHordeDay = toPositiveInteger(firstHordeDay, safeInterval); + + if (safeCurrentDay <= safeFirstHordeDay) { + return safeFirstHordeDay; + } + + const cyclesSinceFirst = Math.ceil((safeCurrentDay - safeFirstHordeDay) / safeInterval); + return safeFirstHordeDay + cyclesSinceFirst * safeInterval; +} + +export function renderBloodMoonMessage(template, values) { + const replacements = { + currentDay: values.currentDay, + nextHordeDay: values.nextHordeDay, + daysUntil: values.daysUntil, + daysUntilText: values.daysUntilText, + interval: values.interval, + firstHordeDay: values.firstHordeDay, + source: values.source, + }; + + let message = String(template || 'Blood moon: Day {nextHordeDay}. Current day: {currentDay}. {daysUntilText}.'); + for (const [key, value] of Object.entries(replacements)) { + message = message.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value)); + } + return message; +} + +export function buildDaysUntilText(config, daysUntil) { + if (daysUntil === 0) { + return String(config.hordeTodayText || 'Blood moon is tonight'); + } + + const template = String(config.daysUntilText || '{daysUntil} day(s) remaining'); + return template.replace(/\{daysUntil\}/g, String(daysUntil)); +} + +async function readCurrentDayFromConsole(gameServerId, config, takaro) { + const command = String(config.timeConsoleCommand || 'gettime').trim(); + if (command === '') { + throw new Error('Blood moon countdown is misconfigured: timeConsoleCommand is empty.'); + } + + const result = await takaro.gameserver.gameServerControllerExecuteCommand(gameServerId, { command }); + const possibleOutputs = [ + result?.data?.data?.rawResult, + result?.data?.data?.result, + result?.data?.data?.output, + result?.data?.data?.message, + result?.data?.data, + result?.data, + result, + ]; + + for (const possibleOutput of possibleOutputs) { + const day = extractCurrentDay( + typeof possibleOutput === 'string' ? possibleOutput : JSON.stringify(possibleOutput ?? ''), + ); + if (day !== null) { + return { currentDay: day, source: 'console' }; + } + } + + if (config.includeConsoleOutputInLogs ?? true) { + console.warn(`blood-moon-countdown: failed to parse day from console command '${command}'. Result: ${JSON.stringify(result)}`); + } else { + console.warn(`blood-moon-countdown: failed to parse day from console command '${command}'.`); + } + + return null; +} + +export async function resolveCurrentDay(gameServerId, config, takaro) { + if (config.parseConsoleTime ?? true) { + try { + const parsed = await readCurrentDayFromConsole(gameServerId, config, takaro); + if (parsed) return parsed; + } catch (err) { + console.warn(`blood-moon-countdown: console time lookup failed: ${err}`); + } + + if (!(config.allowManualFallbackOnParseFailure ?? false)) { + return null; + } + } + + const fallbackDay = toPositiveInteger(config.manualCurrentDay, null); + if (fallbackDay !== null) { + return { currentDay: fallbackDay, source: 'manual' }; + } + + return null; +} + +export async function buildBloodMoonResponse(gameServerId, config, takaro) { + const resolved = await resolveCurrentDay(gameServerId, config, takaro); + if (!resolved) return null; + + const interval = toPositiveInteger(config.hordeIntervalDays, 7); + const firstHordeDay = toPositiveInteger(config.firstHordeDay, interval); + const nextHordeDay = calculateNextHordeDay(resolved.currentDay, interval, firstHordeDay); + const daysUntil = Math.max(0, nextHordeDay - resolved.currentDay); + const daysUntilText = buildDaysUntilText(config, daysUntil); + const message = renderBloodMoonMessage(config.responseTemplate, { + currentDay: resolved.currentDay, + nextHordeDay, + daysUntil, + daysUntilText, + interval, + firstHordeDay, + source: resolved.source, + }); + + return { + message, + currentDay: resolved.currentDay, + nextHordeDay, + daysUntil, + source: resolved.source, + }; +} diff --git a/modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js b/modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js new file mode 100644 index 0000000..793cfa4 --- /dev/null +++ b/modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js @@ -0,0 +1,48 @@ +import { data, takaro } from '@takaro/helpers'; +import { buildBloodMoonResponse } from './blood-moon-helpers.js'; + +function getDiscordChannelId(mod) { + return mod?.systemConfig?.hooks?.discordBloodmoon?.discordChannelId || + mod?.systemConfig?.hooks?.DiscordBloodmoon?.discordChannelId || + mod?.systemConfig?.hooks?.DiscordToGame?.discordChannelId || + data.discordChannelId; +} + +function getTriggers(config) { + const configured = Array.isArray(config.discordTriggers) ? config.discordTriggers : ['!bloodmoon', '!day7']; + return configured + .map((trigger) => String(trigger ?? '').trim().toLowerCase()) + .filter((trigger) => trigger !== ''); +} + +async function main() { + const { gameServerId, module: mod, eventData } = data; + const config = mod.userConfig ?? {}; + + if (eventData?.author?.isBot) return; + + const msg = String(eventData?.msg ?? '').trim().toLowerCase(); + const triggers = getTriggers(config); + if (!triggers.includes(msg)) return; + + const discordChannelId = getDiscordChannelId(mod); + if (!discordChannelId) { + console.warn('blood-moon-countdown: discord trigger matched but no Discord channel is configured for discordBloodmoon hook'); + return; + } + + const result = await buildBloodMoonResponse(gameServerId, config, takaro); + const message = result?.message || + config.parseErrorMessage || + "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config or set manualCurrentDay."; + + await takaro.discord.discordControllerSendMessage(discordChannelId, { message }); + + if (result) { + console.log( + `blood-moon-countdown: discord reply currentDay=${result.currentDay}, nextHordeDay=${result.nextHordeDay}, daysUntil=${result.daysUntil}, source=${result.source}`, + ); + } +} + +await main(); diff --git a/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts b/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts new file mode 100644 index 0000000..1cce86a --- /dev/null +++ b/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts @@ -0,0 +1,50 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + calculateNextHordeDay, + extractCurrentDay, + buildDaysUntilText, + renderBloodMoonMessage, +} from '../src/functions/blood-moon-helpers.js'; + +describe('blood-moon-countdown helper behavior', () => { + it('parses common 7D2D gettime outputs', () => { + assert.equal(extractCurrentDay('Day 12, 14:30'), 12); + assert.equal(extractCurrentDay('Game time: Day 27 06:00'), 27); + assert.equal(extractCurrentDay('d: 7 h: 22 m: 00'), 7); + }); + + it('returns null when no current day is present', () => { + assert.equal(extractCurrentDay('Server is running'), null); + }); + + it('calculates the next vanilla horde day including today', () => { + assert.equal(calculateNextHordeDay(1, 7, 7), 7); + assert.equal(calculateNextHordeDay(7, 7, 7), 7); + assert.equal(calculateNextHordeDay(8, 7, 7), 14); + assert.equal(calculateNextHordeDay(14, 7, 7), 14); + assert.equal(calculateNextHordeDay(15, 7, 7), 21); + }); + + it('supports custom interval and first horde day', () => { + assert.equal(calculateNextHordeDay(3, 5, 5), 5); + assert.equal(calculateNextHordeDay(6, 5, 5), 10); + assert.equal(calculateNextHordeDay(17, 5, 5), 20); + }); + + it('renders today and future day text through the response template', () => { + assert.equal(buildDaysUntilText({ hordeTodayText: 'Horde night is tonight!' }, 0), 'Horde night is tonight!'); + assert.equal(buildDaysUntilText({ daysUntilText: '{daysUntil} days left' }, 3), '3 days left'); + + const message = renderBloodMoonMessage('Current {currentDay}; next {nextHordeDay}; {daysUntilText}; source={source}', { + currentDay: 11, + nextHordeDay: 14, + daysUntil: 3, + daysUntilText: '3 days left', + interval: 7, + firstHordeDay: 7, + source: 'console', + }); + assert.equal(message, 'Current 11; next 14; 3 days left; source=console'); + }); +}); From 3c2e13fc691e2ac05cb3ca9b19593854863a4513 Mon Sep 17 00:00:00 2001 From: El-Limon Date: Mon, 20 Jul 2026 21:51:07 +0200 Subject: [PATCH 2/2] fix: harden blood moon countdown for 7d2d --- modules/blood-moon-countdown/module.json | 44 +--- .../src/commands/bloodmoon/index.js | 6 +- .../src/functions/blood-moon-helpers.d.ts | 15 -- .../src/functions/blood-moon-helpers.js | 41 ++-- .../src/hooks/discord-bloodmoon/index.js | 48 ---- .../test/blood-moon-countdown.test.ts | 209 ++++++++++++++---- 6 files changed, 198 insertions(+), 165 deletions(-) delete mode 100644 modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts delete mode 100644 modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js diff --git a/modules/blood-moon-countdown/module.json b/modules/blood-moon-countdown/module.json index 975003d..5098d01 100644 --- a/modules/blood-moon-countdown/module.json +++ b/modules/blood-moon-countdown/module.json @@ -1,10 +1,10 @@ { "name": "blood-moon-countdown", "author": "Takaro", - "description": "7 Days to Die helper commands and optional Discord replies that tell players how many in-game days remain until the next blood moon/horde night. The interval, first horde day, console command, manual fallback day, Discord triggers, and player-facing messages are editable in module config.", + "description": "7 Days to Die helper commands that tell players how many in-game days remain until the next blood moon/horde night using live server time.", "version": "latest", "supportedGames": [ - "all" + "7 days to die" ], "config": { "$schema": "http://json-schema.org/draft-07/schema#", @@ -28,22 +28,6 @@ "maxLength": 80, "description": "Console command used to read current 7D2D game time. The output must include the current day, such as 'Day 12'." }, - "manualCurrentDay": { - "type": "integer", - "minimum": 1, - "default": 1, - "description": "Manual current day used when parseConsoleTime is disabled, or as fallback when allowManualFallbackOnParseFailure is enabled." - }, - "allowManualFallbackOnParseFailure": { - "type": "boolean", - "default": false, - "description": "When enabled, use manualCurrentDay if the console command fails or cannot be parsed. Leave disabled to avoid stale countdowns when live time lookup breaks." - }, - "parseConsoleTime": { - "type": "boolean", - "default": true, - "description": "When enabled, /bloodmoon and /day7 run the console command and parse the current day. Disable to use manualCurrentDay only." - }, "responseTemplate": { "type": "string", "default": "Blood moon: Day {nextHordeDay}. Current day: {currentDay}. {daysUntilText}.", @@ -64,31 +48,19 @@ }, "parseErrorMessage": { "type": "string", - "default": "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config or set manualCurrentDay.", + "default": "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config and timeConsoleCommand.", "maxLength": 300, - "description": "Player-facing message when console time parsing fails and no manual fallback can be used." + "description": "Player-facing message when console time parsing fails." }, "includeConsoleOutputInLogs": { "type": "boolean", "default": true, "description": "Log the raw console output when parsing fails. Useful while tuning timeConsoleCommand." - }, - "discordTriggers": { - "type": "array", - "default": ["!bloodmoon", "!day7"], - "description": "Discord message triggers that make the module reply in Discord. Requires the discordBloodmoon hook to be configured with a Discord channel.", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 40 - } } }, "required": [], "additionalProperties": false }, - "uiSchema": {}, - "permissions": [], "commands": { "bloodmoon": { "trigger": "bloodmoon", @@ -105,14 +77,6 @@ "arguments": [] } }, - "hooks": { - "discordBloodmoon": { - "eventType": "discord-message", - "description": "Reply in Discord when someone types a configured trigger such as !bloodmoon or !day7.", - "function": "src/hooks/discord-bloodmoon/index.js" - } - }, - "cronJobs": {}, "functions": { "blood-moon-helpers": { "function": "src/functions/blood-moon-helpers.js" diff --git a/modules/blood-moon-countdown/src/commands/bloodmoon/index.js b/modules/blood-moon-countdown/src/commands/bloodmoon/index.js index d2cf45e..f6116f8 100644 --- a/modules/blood-moon-countdown/src/commands/bloodmoon/index.js +++ b/modules/blood-moon-countdown/src/commands/bloodmoon/index.js @@ -2,21 +2,21 @@ import { data, takaro, TakaroUserError } from '@takaro/helpers'; import { buildBloodMoonResponse } from './blood-moon-helpers.js'; async function main() { - const { gameServerId, pog, module: mod } = data; + const { gameServerId, player, module: mod } = data; const config = mod.userConfig ?? {}; const result = await buildBloodMoonResponse(gameServerId, config, takaro); if (!result) { throw new TakaroUserError( config.parseErrorMessage || - "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config or set manualCurrentDay.", + "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config and timeConsoleCommand.", ); } console.log( `blood-moon-countdown: currentDay=${result.currentDay}, nextHordeDay=${result.nextHordeDay}, daysUntil=${result.daysUntil}, source=${result.source}`, ); - await pog.pm(result.message); + await player.pm(result.message); } await main(); diff --git a/modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts deleted file mode 100644 index 34c654e..0000000 --- a/modules/blood-moon-countdown/src/functions/blood-moon-helpers.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function toPositiveInteger(value: unknown, fallback: number | null): number | null; -export function extractCurrentDay(rawOutput: unknown): number | null; -export function calculateNextHordeDay(currentDay: unknown, interval: unknown, firstHordeDay: unknown): number; -export function renderBloodMoonMessage(template: unknown, values: Record): string; -export function buildDaysUntilText(config: Record, daysUntil: number): string; -export function resolveCurrentDay( - gameServerId: string, - config: Record, - takaro: unknown, -): Promise<{ currentDay: number; source: string } | null>; -export function buildBloodMoonResponse( - gameServerId: string, - config: Record, - takaro: unknown, -): Promise<{ message: string; currentDay: number; nextHordeDay: number; daysUntil: number; source: string } | null>; diff --git a/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js index 2eb0a18..8557956 100644 --- a/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js +++ b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js @@ -65,6 +65,16 @@ export function buildDaysUntilText(config, daysUntil) { return template.replace(/\{daysUntil\}/g, String(daysUntil)); } +function serializeConsoleOutput(value) { + if (typeof value === 'string') return value; + + try { + return JSON.stringify(value ?? ''); + } catch { + return String(value ?? ''); + } +} + async function readCurrentDayFromConsole(gameServerId, config, takaro) { const command = String(config.timeConsoleCommand || 'gettime').trim(); if (command === '') { @@ -79,20 +89,20 @@ async function readCurrentDayFromConsole(gameServerId, config, takaro) { result?.data?.data?.message, result?.data?.data, result?.data, - result, ]; for (const possibleOutput of possibleOutputs) { - const day = extractCurrentDay( - typeof possibleOutput === 'string' ? possibleOutput : JSON.stringify(possibleOutput ?? ''), - ); + const day = extractCurrentDay(serializeConsoleOutput(possibleOutput)); if (day !== null) { return { currentDay: day, source: 'console' }; } } if (config.includeConsoleOutputInLogs ?? true) { - console.warn(`blood-moon-countdown: failed to parse day from console command '${command}'. Result: ${JSON.stringify(result)}`); + const diagnosticOutput = result?.data?.data?.rawResult ?? result?.data?.data ?? result?.data; + console.warn( + `blood-moon-countdown: failed to parse day from console command '${command}'. Result: ${serializeConsoleOutput(diagnosticOutput)}`, + ); } else { console.warn(`blood-moon-countdown: failed to parse day from console command '${command}'.`); } @@ -101,22 +111,11 @@ async function readCurrentDayFromConsole(gameServerId, config, takaro) { } export async function resolveCurrentDay(gameServerId, config, takaro) { - if (config.parseConsoleTime ?? true) { - try { - const parsed = await readCurrentDayFromConsole(gameServerId, config, takaro); - if (parsed) return parsed; - } catch (err) { - console.warn(`blood-moon-countdown: console time lookup failed: ${err}`); - } - - if (!(config.allowManualFallbackOnParseFailure ?? false)) { - return null; - } - } - - const fallbackDay = toPositiveInteger(config.manualCurrentDay, null); - if (fallbackDay !== null) { - return { currentDay: fallbackDay, source: 'manual' }; + try { + const parsed = await readCurrentDayFromConsole(gameServerId, config, takaro); + if (parsed) return parsed; + } catch (err) { + console.warn(`blood-moon-countdown: console time lookup failed: ${err}`); } return null; diff --git a/modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js b/modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js deleted file mode 100644 index 793cfa4..0000000 --- a/modules/blood-moon-countdown/src/hooks/discord-bloodmoon/index.js +++ /dev/null @@ -1,48 +0,0 @@ -import { data, takaro } from '@takaro/helpers'; -import { buildBloodMoonResponse } from './blood-moon-helpers.js'; - -function getDiscordChannelId(mod) { - return mod?.systemConfig?.hooks?.discordBloodmoon?.discordChannelId || - mod?.systemConfig?.hooks?.DiscordBloodmoon?.discordChannelId || - mod?.systemConfig?.hooks?.DiscordToGame?.discordChannelId || - data.discordChannelId; -} - -function getTriggers(config) { - const configured = Array.isArray(config.discordTriggers) ? config.discordTriggers : ['!bloodmoon', '!day7']; - return configured - .map((trigger) => String(trigger ?? '').trim().toLowerCase()) - .filter((trigger) => trigger !== ''); -} - -async function main() { - const { gameServerId, module: mod, eventData } = data; - const config = mod.userConfig ?? {}; - - if (eventData?.author?.isBot) return; - - const msg = String(eventData?.msg ?? '').trim().toLowerCase(); - const triggers = getTriggers(config); - if (!triggers.includes(msg)) return; - - const discordChannelId = getDiscordChannelId(mod); - if (!discordChannelId) { - console.warn('blood-moon-countdown: discord trigger matched but no Discord channel is configured for discordBloodmoon hook'); - return; - } - - const result = await buildBloodMoonResponse(gameServerId, config, takaro); - const message = result?.message || - config.parseErrorMessage || - "I couldn't read the current game day. Ask an admin to check the blood-moon-countdown module config or set manualCurrentDay."; - - await takaro.discord.discordControllerSendMessage(discordChannelId, { message }); - - if (result) { - console.log( - `blood-moon-countdown: discord reply currentDay=${result.currentDay}, nextHordeDay=${result.nextHordeDay}, daysUntil=${result.daysUntil}, source=${result.source}`, - ); - } -} - -await main(); diff --git a/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts b/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts index 1cce86a..9ae7d59 100644 --- a/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts +++ b/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts @@ -1,50 +1,183 @@ -import { describe, it } from 'node:test'; +import { after, before, describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Client, EventSearchInputAllowedFiltersEventNameEnum, ModuleOutputDTO } from '@takaro/apiclient'; +import { createClient } from '../../../test/helpers/client.js'; +import { waitForEvent } from '../../../test/helpers/events.js'; +import { startMockServer, stopMockServer, MockServerContext } from '../../../test/helpers/mock-server.js'; import { - calculateNextHordeDay, - extractCurrentDay, - buildDaysUntilText, - renderBloodMoonMessage, -} from '../src/functions/blood-moon-helpers.js'; - -describe('blood-moon-countdown helper behavior', () => { - it('parses common 7D2D gettime outputs', () => { - assert.equal(extractCurrentDay('Day 12, 14:30'), 12); - assert.equal(extractCurrentDay('Game time: Day 27 06:00'), 27); - assert.equal(extractCurrentDay('d: 7 h: 22 m: 00'), 7); - }); + cleanupTestGameServers, + cleanupTestModules, + deleteModule, + getCommandPrefix, + installModule, + pushModule, + uninstallModule, +} from '../../../test/helpers/modules.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MODULE_DIR = path.resolve(__dirname, '..'); + +describe('blood-moon-countdown commands', () => { + let client: Client; + let ctx: MockServerContext; + let mod: ModuleOutputDTO; + let prefix: string; + let testModuleDir: string; + + before(async () => { + client = await createClient(); + await cleanupTestModules(client); + await cleanupTestGameServers(client); + ctx = await startMockServer(client); - it('returns null when no current day is present', () => { - assert.equal(extractCurrentDay('Server is running'), null); + // Never replace a real module with the same name in the shared test domain. + testModuleDir = await fs.mkdtemp(path.join(os.tmpdir(), 'test-blood-moon-countdown-')); + await fs.cp(MODULE_DIR, testModuleDir, { recursive: true }); + const moduleJsonPath = path.join(testModuleDir, 'module.json'); + const moduleJson = JSON.parse(await fs.readFile(moduleJsonPath, 'utf8')) as { name: string }; + moduleJson.name = 'test-blood-moon-countdown'; + await fs.writeFile(moduleJsonPath, `${JSON.stringify(moduleJson, null, 2)}\n`); + + mod = await pushModule(client, testModuleDir); + await installModule(client, mod.latestVersion.id, ctx.gameServer.id, { + userConfig: { + // The mock server returns this text through the real console-command API. + // It exercises the same parser shape as live 7D2D `gettime` output. + timeConsoleCommand: 'say Day 134, 23:15', + }, + }); + prefix = await getCommandPrefix(client, ctx.gameServer.id); }); - it('calculates the next vanilla horde day including today', () => { - assert.equal(calculateNextHordeDay(1, 7, 7), 7); - assert.equal(calculateNextHordeDay(7, 7, 7), 7); - assert.equal(calculateNextHordeDay(8, 7, 7), 14); - assert.equal(calculateNextHordeDay(14, 7, 7), 14); - assert.equal(calculateNextHordeDay(15, 7, 7), 21); + after(async () => { + try { + await uninstallModule(client, mod.id, ctx.gameServer.id); + } catch (err) { + console.error('Cleanup: failed to uninstall module:', err); + } + try { + await deleteModule(client, mod.id); + } catch (err) { + console.error('Cleanup: failed to delete module:', err); + } + await stopMockServer(ctx.server, client, ctx.gameServer.id); + await fs.rm(testModuleDir, { recursive: true, force: true }); }); - it('supports custom interval and first horde day', () => { - assert.equal(calculateNextHordeDay(3, 5, 5), 5); - assert.equal(calculateNextHordeDay(6, 5, 5), 10); - assert.equal(calculateNextHordeDay(17, 5, 5), 20); + async function triggerCommand(trigger: 'bloodmoon' | 'day7') { + const beforeTrigger = new Date(); + const player = ctx.players[0]!; + + await client.command.commandControllerTrigger(ctx.gameServer.id, { + msg: `${prefix}${trigger}`, + playerId: player.playerId, + }); + + return waitForEvent(client, { + eventName: EventSearchInputAllowedFiltersEventNameEnum.CommandExecuted, + gameserverId: ctx.gameServer.id, + after: beforeTrigger, + timeout: 30000, + }); + } + + for (const trigger of ['bloodmoon', 'day7'] as const) { + it(`executes ${trigger} with live-format console time`, async () => { + const event = await triggerCommand(trigger); + const meta = event.meta as { + result?: { + success?: boolean; + logs?: Array<{ + msg: string; + details?: { + args?: Array<{ + body?: { + message?: string; + opts?: { recipient?: { gameId?: string } }; + }; + }>; + }; + }>; + }; + }; + const logEntries = meta.result?.logs ?? []; + const logs = logEntries.map((entry) => entry.msg); + + assert.equal(meta.result?.success, true, `Expected ${trigger} to succeed, logs: ${JSON.stringify(logs)}`); + assert.ok( + logs.some( + (msg) => + msg.includes('currentDay=134') && + msg.includes('nextHordeDay=140') && + msg.includes('daysUntil=6') && + msg.includes('source=console'), + ), + `Expected parsed countdown proof in logs, got: ${JSON.stringify(logs)}`, + ); + assert.ok( + logEntries.some((entry) => + entry.details?.args?.some( + (argument) => + argument.body?.message === 'Blood moon: Day 140. Current day: 134. 6 day(s) remaining.' && + argument.body.opts?.recipient?.gameId === ctx.players[0]!.gameId, + ), + ), + `Expected a private countdown message with a player recipient, got: ${JSON.stringify(logEntries)}`, + ); + }); + } + + it('imports only the approved 7D2D command surface', () => { + assert.deepEqual(mod.supportedGames, ['7 days to die']); + assert.deepEqual( + mod.latestVersion.commands.map((command) => command.name).sort(), + ['bloodmoon', 'day7'], + ); + assert.deepEqual(mod.latestVersion.hooks, []); }); - it('renders today and future day text through the response template', () => { - assert.equal(buildDaysUntilText({ hordeTodayText: 'Horde night is tonight!' }, 0), 'Horde night is tonight!'); - assert.equal(buildDaysUntilText({ daysUntilText: '{daysUntil} days left' }, 3), '3 days left'); - - const message = renderBloodMoonMessage('Current {currentDay}; next {nextHordeDay}; {daysUntilText}; source={source}', { - currentDay: 11, - nextHordeDay: 14, - daysUntil: 3, - daysUntilText: '3 days left', - interval: 7, - firstHordeDay: 7, - source: 'console', + it('rejects unparseable console time without sending a fallback countdown', async () => { + const parseErrorMessage = 'Blood moon time is unavailable.'; + await uninstallModule(client, mod.id, ctx.gameServer.id); + await installModule(client, mod.latestVersion.id, ctx.gameServer.id, { + userConfig: { + // `version` succeeds on the mock connector but contains no parseable day. + timeConsoleCommand: 'version', + parseErrorMessage, + }, }); - assert.equal(message, 'Current 11; next 14; 3 days left; source=console'); + + const event = await triggerCommand('bloodmoon'); + const meta = event.meta as { + result?: { + success?: boolean; + logs?: Array<{ + msg: string; + details?: unknown; + }>; + }; + }; + const result = meta.result; + const logEntries = result?.logs ?? []; + const serializedLogs = JSON.stringify(logEntries); + + assert.equal(result?.success, false, `Expected parse failure, got: ${JSON.stringify(result)}`); + assert.ok( + serializedLogs.includes(parseErrorMessage) && serializedLogs.includes('TakaroUserError'), + `Expected configured player error, got: ${JSON.stringify(result)}`, + ); + assert.ok( + logEntries.some((entry) => entry.msg.includes('failed to parse day')), + `Expected parse-failure diagnostics, got: ${JSON.stringify(logEntries)}`, + ); + assert.ok( + !serializedLogs.includes('"body":{"message"'), + `Expected no fallback countdown message, got: ${JSON.stringify(logEntries)}`, + ); }); });