Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions modules/blood-moon-countdown/module.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
22 changes: 22 additions & 0 deletions modules/blood-moon-countdown/src/commands/bloodmoon/index.js
Original file line number Diff line number Diff line change
@@ -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();
150 changes: 150 additions & 0 deletions modules/blood-moon-countdown/src/functions/blood-moon-helpers.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading