diff --git a/modules/blood-moon-countdown/module.json b/modules/blood-moon-countdown/module.json new file mode 100644 index 0000000..5098d01 --- /dev/null +++ b/modules/blood-moon-countdown/module.json @@ -0,0 +1,85 @@ +{ + "name": "blood-moon-countdown", + "author": "Takaro", + "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": [ + "7 days to die" + ], + "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'." + }, + "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 and timeConsoleCommand.", + "maxLength": 300, + "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." + } + }, + "required": [], + "additionalProperties": false + }, + "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": [] + } + }, + "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..f6116f8 --- /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, 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 and timeConsoleCommand.", + ); + } + + console.log( + `blood-moon-countdown: currentDay=${result.currentDay}, nextHordeDay=${result.nextHordeDay}, daysUntil=${result.daysUntil}, source=${result.source}`, + ); + await player.pm(result.message); +} + +await main(); 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..8557956 --- /dev/null +++ b/modules/blood-moon-countdown/src/functions/blood-moon-helpers.js @@ -0,0 +1,150 @@ +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)); +} + +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 === '') { + 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, + ]; + + for (const possibleOutput of possibleOutputs) { + const day = extractCurrentDay(serializeConsoleOutput(possibleOutput)); + if (day !== null) { + return { currentDay: day, source: 'console' }; + } + } + + if (config.includeConsoleOutputInLogs ?? true) { + 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}'.`); + } + + return null; +} + +export async function resolveCurrentDay(gameServerId, config, takaro) { + 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; +} + +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/test/blood-moon-countdown.test.ts b/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts new file mode 100644 index 0000000..9ae7d59 --- /dev/null +++ b/modules/blood-moon-countdown/test/blood-moon-countdown.test.ts @@ -0,0 +1,183 @@ +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 { + 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); + + // 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); + }); + + 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 }); + }); + + 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('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, + }, + }); + + 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)}`, + ); + }); +});