updateday 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -2
  3. package/assets/hourglass/10m.png +0 -0
  4. package/assets/hourglass/12h.png +0 -0
  5. package/assets/hourglass/15m.png +0 -0
  6. package/assets/hourglass/1h.png +0 -0
  7. package/assets/hourglass/24h.png +0 -0
  8. package/assets/hourglass/2h.png +0 -0
  9. package/assets/hourglass/30m.png +0 -0
  10. package/assets/hourglass/3h.png +0 -0
  11. package/assets/hourglass/45m.png +0 -0
  12. package/assets/hourglass/4h.png +0 -0
  13. package/assets/hourglass/5h.png +0 -0
  14. package/assets/hourglass/5m.png +0 -0
  15. package/assets/hourglass/6h.png +0 -0
  16. package/assets/hourglass/default.png +0 -0
  17. package/assets/hourglass/now.png +0 -0
  18. package/assets/hourglass-styles/blocky.png +0 -0
  19. package/assets/hourglass-styles/classic.png +0 -0
  20. package/assets/hourglass-styles/glow.png +0 -0
  21. package/assets/hourglass-styles/outline.png +0 -0
  22. package/assets/hourglass-styles/pixel.png +0 -0
  23. package/assets/hourglass-styles/rounded.png +0 -0
  24. package/bin/updateday.js +5 -0
  25. package/dist/bundle.js +54 -0
  26. package/dist/cli.js +254 -0
  27. package/dist/config.js +261 -0
  28. package/dist/doctor.js +119 -0
  29. package/dist/experiments.js +349 -0
  30. package/dist/gamePage.js +143 -0
  31. package/dist/gifts.js +55 -0
  32. package/dist/history.js +68 -0
  33. package/dist/icons.js +216 -0
  34. package/dist/labels.js +64 -0
  35. package/dist/mcp.js +302 -0
  36. package/dist/moderation.js +86 -0
  37. package/dist/notify.js +72 -0
  38. package/dist/openCloud.js +211 -0
  39. package/dist/playerData.js +109 -0
  40. package/dist/preflight.js +202 -0
  41. package/dist/runner.js +360 -0
  42. package/dist/schedule.js +162 -0
  43. package/dist/server.js +929 -0
  44. package/dist/state.js +29 -0
  45. package/dist/swap.js +196 -0
  46. package/dist/zip.js +82 -0
  47. package/docs/AGENTS.md +123 -0
  48. package/docs/COVERAGE.md +50 -0
  49. package/docs/GUIDE.md +288 -0
  50. package/docs/hourglass-styles.png +0 -0
  51. package/docs/screenshot.png +0 -0
  52. package/example/multi-game.json +16 -0
  53. package/example/updateday.json +37 -0
  54. package/package.json +63 -5
  55. package/roblox/UpdateDayBanner.client.luau +159 -0
  56. package/roblox/UpdateDayCountdown.server.luau +48 -0
  57. package/roblox/UpdateDayGifts.luau +119 -0
  58. package/roblox/UpdateDayListener.luau +43 -0
  59. package/templates/github-actions/updateday.yml +35 -0
  60. package/ui/core.js +325 -0
  61. package/ui/countdown.js +775 -0
  62. package/ui/events.js +249 -0
  63. package/ui/gifts.js +215 -0
  64. package/ui/history.js +156 -0
  65. package/ui/index.html +530 -0
  66. package/ui/jsonEditor.js +417 -0
  67. package/ui/page.js +94 -0
  68. package/ui/players.js +256 -0
  69. package/ui/run.js +391 -0
  70. package/ui/sale.js +366 -0
  71. package/ui/settings.js +414 -0
  72. package/ui/shell.js +193 -0
package/dist/icons.js ADDED
@@ -0,0 +1,216 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import sharp from "sharp";
5
+ import { localizedLabel } from "./labels.js";
6
+ export const ICON_SIZE = 512;
7
+ const THUMBNAIL_URL = "https://thumbnails.roblox.com/v1/games/icons";
8
+ const BUNDLED_OVERLAY_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../assets/hourglass");
9
+ export const STYLE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../assets/hourglass-styles");
10
+ export const HOURGLASS_STYLES = ["original", "classic", "pixel", "rounded", "glow", "blocky", "outline"];
11
+ const ORIGINAL_STYLE = "original";
12
+ const TEXT_WIDTH_LIMIT = 0.94;
13
+ const TEXT_BASELINE_SHIFT = 0.36;
14
+ const OVERLAY_EXTENSIONS = [".png", ".webp", ".svg"];
15
+ const LABEL_FONT = "Arial Black, Arial, Noto Sans, Noto Sans CJK SC, Microsoft YaHei, Malgun Gothic, Yu Gothic, Leelawadee UI, Noto Sans Thai, DejaVu Sans, sans-serif";
16
+ const WIDE_CHAR = /[ᄀ-ᇿ⺀-鿿가-힯豈-﫿＀-￯]/;
17
+ const WIDE_CHAR_SCALE = 1.4;
18
+ const DEFAULT_OVERLAY = "default";
19
+ const LABEL_CHAR_WIDTH = 0.74;
20
+ const LABEL_MAX_FONT_RATIO = 0.2;
21
+ const LABEL_OUTLINE_RATIO = 0.22;
22
+ const DEFAULT_ART_HEIGHT_RATIO = 0.74;
23
+ const EDGE_MARGIN = 12;
24
+ const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
25
+ export async function fetchCurrentIcon(universeId) {
26
+ const query = `?universeIds=${universeId}&size=${ICON_SIZE}x${ICON_SIZE}&format=Png&isCircular=false&returnPolicy=PlaceHolder`;
27
+ const response = await fetch(THUMBNAIL_URL + query);
28
+ if (!response.ok) {
29
+ throw new Error(`Could not look up the game icon (${response.status})`);
30
+ }
31
+ const entry = (await response.json()).data?.[0];
32
+ if (!entry?.imageUrl || entry.state !== "Completed") {
33
+ throw new Error(`Game icon isn't available yet (state: ${entry?.state ?? "missing"})`);
34
+ }
35
+ const image = await fetch(entry.imageUrl);
36
+ if (!image.ok) {
37
+ throw new Error(`Could not download the game icon (${image.status})`);
38
+ }
39
+ return Buffer.from(await image.arrayBuffer());
40
+ }
41
+ export async function snapshotIcon(universeId, path) {
42
+ const icon = await sharp(await fetchCurrentIcon(universeId)).resize(ICON_SIZE, ICON_SIZE).png().toBuffer();
43
+ writeFileSync(path, icon);
44
+ return icon;
45
+ }
46
+ function findFile(dir, name) {
47
+ for (const extension of OVERLAY_EXTENSIONS) {
48
+ const path = join(dir, name + extension);
49
+ if (existsSync(path)) {
50
+ return path;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ function findOverlay(dir, mark) {
56
+ const exact = findFile(dir, mark);
57
+ if (exact) {
58
+ return { path: exact, hasText: true };
59
+ }
60
+ const fallback = findFile(dir, "default");
61
+ return fallback ? { path: fallback, hasText: false } : null;
62
+ }
63
+ export function overlayFor(style, mark) {
64
+ const userOverlay = style.overlayDir ? findOverlay(style.overlayDir, mark) : null;
65
+ const overlay = userOverlay ?? findOverlay(BUNDLED_OVERLAY_DIR, mark);
66
+ if (!overlay) {
67
+ throw new Error(`No hourglass overlay found for "${mark}"`);
68
+ }
69
+ return overlay;
70
+ }
71
+ export function labelText(mark) {
72
+ if (mark === "now") {
73
+ return "NOW!";
74
+ }
75
+ const match = /^(\d+)([smhd])$/.exec(mark);
76
+ if (!match) {
77
+ return mark.toUpperCase();
78
+ }
79
+ const count = Number(match[1]);
80
+ const units = { s: "SEC", m: "MIN", h: "HOUR", d: "DAY" };
81
+ return `${count} ${units[match[2]]}${count === 1 ? "" : "S"}`;
82
+ }
83
+ function escapeXml(text) {
84
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
85
+ }
86
+ function placement(style) {
87
+ if (style.anchor === "full") {
88
+ return { x: 0, y: 0, size: ICON_SIZE };
89
+ }
90
+ const size = Math.round(ICON_SIZE * style.scale);
91
+ const far = ICON_SIZE - EDGE_MARGIN - size;
92
+ const middle = Math.round((ICON_SIZE - size) / 2);
93
+ if (style.anchor === "center") {
94
+ return { x: middle, y: middle, size };
95
+ }
96
+ const x = style.anchor.endsWith("right") ? far : EDGE_MARGIN;
97
+ const y = style.anchor.startsWith("top") ? EDGE_MARGIN : far;
98
+ return { x, y, size };
99
+ }
100
+ function dimLayer(opacity) {
101
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}">
102
+ <rect width="100%" height="100%" fill="#000000" fill-opacity="${opacity}"/>
103
+ </svg>`;
104
+ return Buffer.from(svg);
105
+ }
106
+ function visualLength(text) {
107
+ let length = 0;
108
+ for (const char of text) {
109
+ length += WIDE_CHAR.test(char) ? WIDE_CHAR_SCALE : 1;
110
+ }
111
+ return length;
112
+ }
113
+ function labelSvg(text, box, style) {
114
+ const fontSize = Math.round(Math.min(box.size * LABEL_MAX_FONT_RATIO, box.size / (LABEL_CHAR_WIDTH * visualLength(text))));
115
+ const outline = Math.round(fontSize * LABEL_OUTLINE_RATIO);
116
+ const centerX = box.x + box.size / 2;
117
+ const baseline = Math.round(box.y + box.size - outline / 2);
118
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}">
119
+ <text x="${centerX}" y="${baseline}" text-anchor="middle" font-family="${LABEL_FONT}" font-weight="900"
120
+ font-size="${fontSize}" fill="${style.labelColor}" stroke="${style.labelOutline}" stroke-width="${outline}"
121
+ stroke-linejoin="round" paint-order="stroke">${escapeXml(text)}</text>
122
+ </svg>`;
123
+ return Buffer.from(svg);
124
+ }
125
+ function customIcon(dir, mark) {
126
+ if (!dir) {
127
+ return null;
128
+ }
129
+ const path = join(dir, `${mark}.png`);
130
+ return existsSync(path) ? path : null;
131
+ }
132
+ function plainIcon(source) {
133
+ return sharp(source).resize(ICON_SIZE, ICON_SIZE).png().toBuffer();
134
+ }
135
+ function baseFor(original, mark, options) {
136
+ if (mark === "now" && options.newIcon && options.newIconAt === "now") {
137
+ return options.newIcon;
138
+ }
139
+ return original;
140
+ }
141
+ export async function renderIcon(original, mark, options, languageCode) {
142
+ if (mark === "final") {
143
+ return plainIcon(options.newIcon ?? original);
144
+ }
145
+ const custom = customIcon(options.customDir, mark);
146
+ if (custom) {
147
+ return plainIcon(custom);
148
+ }
149
+ const base = await plainIcon(baseFor(original, mark, options));
150
+ const translated = languageCode && options.localizeLabels !== false ? localizedLabel(mark, languageCode) : null;
151
+ const ownText = options.labels?.[mark]?.trim() || null;
152
+ return composeIcon(base, mark, options, translated ?? ownText);
153
+ }
154
+ function usesStyle(style) {
155
+ return Boolean(style.style) && style.style !== ORIGINAL_STYLE && !style.overlayDir;
156
+ }
157
+ export function stylePath(name) {
158
+ return join(STYLE_DIR, `${name}.png`);
159
+ }
160
+ function textSvg(text, centerY, sizeRatio, style) {
161
+ const widest = (ICON_SIZE * TEXT_WIDTH_LIMIT) / (LABEL_CHAR_WIDTH * visualLength(text));
162
+ const fontSize = Math.round(Math.min(ICON_SIZE * sizeRatio, widest));
163
+ const outline = Math.round(fontSize * LABEL_OUTLINE_RATIO);
164
+ const baseline = Math.round(centerY + fontSize * TEXT_BASELINE_SHIFT);
165
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ICON_SIZE}" height="${ICON_SIZE}">
166
+ <text x="${ICON_SIZE / 2}" y="${baseline}" text-anchor="middle" font-family="${LABEL_FONT}" font-weight="900"
167
+ font-size="${fontSize}" fill="${style.labelColor}" stroke="${style.labelOutline}" stroke-width="${outline}"
168
+ stroke-linejoin="round" paint-order="stroke">${escapeXml(text)}</text>
169
+ </svg>`;
170
+ return Buffer.from(svg);
171
+ }
172
+ function clamp(value, low, high) {
173
+ return Math.min(high, Math.max(low, value));
174
+ }
175
+ async function composeStyled(base, mark, style, labelOverride) {
176
+ const size = Math.round(ICON_SIZE * clamp(style.hourglassSize ?? 0.66, 0.1, 1));
177
+ const centerY = ICON_SIZE * clamp(style.hourglassY ?? 0.4, 0, 1);
178
+ const top = Math.round(clamp(centerY - size / 2, 0, ICON_SIZE - size));
179
+ const left = Math.round((ICON_SIZE - size) / 2);
180
+ const art = await sharp(stylePath(style.style)).resize(size, size, { fit: "contain", background: TRANSPARENT }).png().toBuffer();
181
+ const layers = [];
182
+ if (style.dim > 0) {
183
+ layers.push({ input: dimLayer(Math.min(style.dim, 1)) });
184
+ }
185
+ layers.push({ input: art, left, top });
186
+ if (style.label) {
187
+ layers.push({ input: textSvg(labelOverride || labelText(mark), ICON_SIZE * clamp(style.textY ?? 0.85, 0, 1), clamp(style.textSize ?? 0.15, 0.04, 0.4), style) });
188
+ }
189
+ return sharp(base).resize(ICON_SIZE, ICON_SIZE).composite(layers).png().toBuffer();
190
+ }
191
+ export async function composeIcon(base, mark, style, labelOverride) {
192
+ if (usesStyle(style)) {
193
+ return composeStyled(base, mark, style, labelOverride);
194
+ }
195
+ const overlay = overlayFor(style, labelOverride ? DEFAULT_OVERLAY : mark);
196
+ const box = placement(style);
197
+ const drawLabel = Boolean(labelOverride) || (style.label && !overlay.hasText);
198
+ const artHeight = drawLabel ? Math.round(box.size * DEFAULT_ART_HEIGHT_RATIO) : box.size;
199
+ const art = await sharp(overlay.path, { density: 300 })
200
+ .resize(box.size, artHeight, { fit: "contain", background: TRANSPARENT })
201
+ .png()
202
+ .toBuffer();
203
+ const layers = [];
204
+ if (style.dim > 0) {
205
+ layers.push({ input: dimLayer(Math.min(style.dim, 1)) });
206
+ }
207
+ layers.push({ input: art, left: box.x, top: box.y });
208
+ if (drawLabel) {
209
+ layers.push({ input: labelSvg(labelOverride || labelText(mark), box, style) });
210
+ }
211
+ return sharp(base)
212
+ .resize(ICON_SIZE, ICON_SIZE)
213
+ .composite(layers)
214
+ .png()
215
+ .toBuffer();
216
+ }
package/dist/labels.js ADDED
@@ -0,0 +1,64 @@
1
+ function simple(one, many) {
2
+ return (count) => (count === 1 ? one : many);
3
+ }
4
+ function same(word) {
5
+ return () => word;
6
+ }
7
+ function slavic(one, few, many) {
8
+ return (count) => {
9
+ const lastTwo = count % 100;
10
+ const last = count % 10;
11
+ if (last === 1 && lastTwo !== 11) {
12
+ return one;
13
+ }
14
+ if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) {
15
+ return few;
16
+ }
17
+ return many;
18
+ };
19
+ }
20
+ function polish(one, few, many) {
21
+ const rule = slavic(one, few, many);
22
+ return (count) => (count === 1 ? one : count % 10 === 1 ? many : rule(count));
23
+ }
24
+ const LANGUAGES = {
25
+ de: { hour: simple("STUNDE", "STUNDEN"), minute: simple("MINUTE", "MINUTEN"), now: "JETZT!" },
26
+ es: { hour: simple("HORA", "HORAS"), minute: simple("MINUTO", "MINUTOS"), now: "¡YA!" },
27
+ fr: { hour: simple("HEURE", "HEURES"), minute: simple("MINUTE", "MINUTES"), now: "MAINTENANT !" },
28
+ pt: { hour: simple("HORA", "HORAS"), minute: simple("MINUTO", "MINUTOS"), now: "AGORA!" },
29
+ it: { hour: simple("ORA", "ORE"), minute: simple("MINUTO", "MINUTI"), now: "ADESSO!" },
30
+ pl: { hour: polish("GODZINA", "GODZINY", "GODZIN"), minute: polish("MINUTA", "MINUTY", "MINUT"), now: "TERAZ!" },
31
+ ru: { hour: slavic("ЧАС", "ЧАСА", "ЧАСОВ"), minute: slavic("МИНУТА", "МИНУТЫ", "МИНУТ"), now: "СЕЙЧАС!" },
32
+ tr: { hour: same("SAAT"), minute: same("DAKİKA"), now: "ŞİMDİ!" },
33
+ id: { hour: same("JAM"), minute: same("MENIT"), now: "SEKARANG!" },
34
+ vi: { hour: same("GIỜ"), minute: same("PHÚT"), now: "BẮT ĐẦU!" },
35
+ th: { hour: same("ชั่วโมง"), minute: same("นาที"), now: "ตอนนี้!" },
36
+ ja: { hour: same("時間"), minute: same("分"), now: "今すぐ!", joined: true },
37
+ ko: { hour: same("시간"), minute: same("분"), now: "지금!", joined: true },
38
+ "zh-hans": { hour: same("小时"), minute: same("分钟"), now: "现在!", joined: true },
39
+ "zh-hant": { hour: same("小時"), minute: same("分鐘"), now: "現在!", joined: true },
40
+ };
41
+ function unitsFor(languageCode) {
42
+ const code = languageCode.toLowerCase().replace("_", "-");
43
+ return LANGUAGES[code] ?? LANGUAGES[code.split("-")[0]] ?? (code.startsWith("zh") ? LANGUAGES["zh-hans"] : null);
44
+ }
45
+ export function localizedLabel(mark, languageCode) {
46
+ const units = unitsFor(languageCode);
47
+ if (!units) {
48
+ return null;
49
+ }
50
+ if (mark === "now") {
51
+ return units.now;
52
+ }
53
+ const match = /^(\d+)([mh])$/.exec(mark);
54
+ if (!match) {
55
+ return null;
56
+ }
57
+ const count = Number(match[1]);
58
+ const word = match[2] === "h" ? units.hour(count) : units.minute(count);
59
+ return units.joined ? `${count}${word}` : `${count} ${word}`;
60
+ }
61
+ export function hasTranslation(languageCode) {
62
+ return unitsFor(languageCode) !== null;
63
+ }
64
+ export const LABEL_LANGUAGES = Object.keys(LANGUAGES);
package/dist/mcp.js ADDED
@@ -0,0 +1,302 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { createInterface } from "node:readline";
4
+ import sharp from "sharp";
5
+ import { nextOccurrence, parseConfigs } from "./config.js";
6
+ import { readHistory, recording } from "./history.js";
7
+ import { fetchCurrentIcon, ICON_SIZE, renderIcon } from "./icons.js";
8
+ import { discordNotifier } from "./notify.js";
9
+ import { OpenCloud } from "./openCloud.js";
10
+ import { preflight } from "./preflight.js";
11
+ import { endCountdown, restorePrices, tick } from "./runner.js";
12
+ import { buildSchedule } from "./schedule.js";
13
+ import { loadState, saveState } from "./state.js";
14
+ const SERVER_INFO = { name: "updateday", version: "0.2.0" };
15
+ const DEFAULT_PROTOCOL = "2025-06-18";
16
+ const HISTORY_LIMIT = 50;
17
+ const GAME_ARG = { type: "string", description: "Game name or universe ID. Leave out for every game." };
18
+ const CONFIRM_ARG = { type: "boolean", description: "Must be true. This changes the live game." };
19
+ function text(value) {
20
+ return { content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] };
21
+ }
22
+ function failure(message) {
23
+ return { content: [{ type: "text", text: message }], isError: true };
24
+ }
25
+ function isPlainObject(value) {
26
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
27
+ }
28
+ export function mergePatch(target, patch) {
29
+ if (!isPlainObject(patch)) {
30
+ return patch;
31
+ }
32
+ const result = isPlainObject(target) ? { ...target } : {};
33
+ for (const [key, value] of Object.entries(patch)) {
34
+ if (value === null) {
35
+ delete result[key];
36
+ }
37
+ else {
38
+ result[key] = mergePatch(result[key], value);
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+ class Planner {
44
+ path;
45
+ baseDir;
46
+ constructor(configPath) {
47
+ this.path = resolve(configPath);
48
+ this.baseDir = dirname(this.path);
49
+ }
50
+ raw() {
51
+ return JSON.parse(readFileSync(this.path, "utf8"));
52
+ }
53
+ configs(filter) {
54
+ const all = parseConfigs(this.raw(), this.baseDir);
55
+ if (!filter) {
56
+ return all;
57
+ }
58
+ const picked = all.filter((config) => config.name === filter || String(config.universeId) === String(filter));
59
+ if (!picked.length) {
60
+ throw new Error(`No game called "${filter}". Games: ${all.map((config) => `${config.name} (${config.universeId})`).join(", ")}`);
61
+ }
62
+ return picked;
63
+ }
64
+ write(raw) {
65
+ const configs = parseConfigs(raw, this.baseDir);
66
+ writeFileSync(this.path, JSON.stringify(raw, null, "\t"));
67
+ return configs;
68
+ }
69
+ context(config) {
70
+ const apiKey = process.env[config.apiKeyEnv];
71
+ const webhook = config.notify ? process.env[config.notify.discordWebhookEnv] : undefined;
72
+ return {
73
+ config,
74
+ state: loadState(config.statePath, config.updateAt),
75
+ cloud: apiKey ? new OpenCloud(apiKey, config.universeId) : null,
76
+ now: new Date(),
77
+ notify: discordNotifier(config, webhook),
78
+ };
79
+ }
80
+ }
81
+ function summary(config) {
82
+ return {
83
+ name: config.name,
84
+ universeId: config.universeId,
85
+ updateAt: config.updateAt,
86
+ nextUpdateAt: nextOccurrence(config)?.toISOString() ?? null,
87
+ countdown: config.countdown ? { method: config.countdown.iconMethod, skipped: config.countdown.skip } : false,
88
+ sale: config.sale ? { percentOff: config.sale.percentOff } : false,
89
+ event: Boolean(config.event),
90
+ warnings: Boolean(config.broadcast),
91
+ restartServers: Boolean(config.restartServers),
92
+ alerts: config.notify ? config.notify.events : false,
93
+ hasApiKey: Boolean(process.env[config.apiKeyEnv]),
94
+ };
95
+ }
96
+ async function captured(task) {
97
+ const lines = [];
98
+ const original = { log: console.log, error: console.error };
99
+ console.log = (...args) => {
100
+ lines.push(args.join(" "));
101
+ process.stderr.write(`${args.join(" ")}\n`);
102
+ };
103
+ console.error = (...args) => {
104
+ lines.push(`ERROR ${args.join(" ")}`);
105
+ process.stderr.write(`${args.join(" ")}\n`);
106
+ };
107
+ try {
108
+ await task();
109
+ }
110
+ catch (error) {
111
+ lines.push(`ERROR ${error instanceof Error ? error.message : error}`);
112
+ }
113
+ finally {
114
+ console.log = original.log;
115
+ console.error = original.error;
116
+ }
117
+ return lines;
118
+ }
119
+ function tools(planner) {
120
+ return [
121
+ {
122
+ name: "get_plan",
123
+ description: "Read the UpdateDay plan: the raw config file and a summary of every game (update time, countdown, sale, event, alerts).",
124
+ inputSchema: { type: "object", properties: {} },
125
+ run: async () => text({ file: planner.path, config: planner.raw(), games: planner.configs().map(summary) }),
126
+ },
127
+ {
128
+ name: "change_plan",
129
+ description: "Change the plan with a JSON merge patch (RFC 7396): objects merge, arrays and values replace, null removes a key. Example: {\"updateAt\":\"2026-11-07T18:00:00Z\",\"sale\":{\"percentOff\":40}}. The result is checked before it's saved.",
130
+ inputSchema: { type: "object", properties: { patch: { type: "object" } }, required: ["patch"] },
131
+ run: async ({ patch }) => {
132
+ const next = mergePatch(planner.raw(), patch);
133
+ const configs = planner.write(next);
134
+ return text({ saved: true, games: configs.map(summary) });
135
+ },
136
+ },
137
+ {
138
+ name: "get_schedule",
139
+ description: "List every step of the update day in order, with its time and whether it already ran.",
140
+ inputSchema: { type: "object", properties: { game: GAME_ARG } },
141
+ run: async ({ game }) => text(planner.configs(game).map((config) => {
142
+ const state = loadState(config.statePath, config.updateAt);
143
+ return {
144
+ game: config.name,
145
+ steps: buildSchedule(config).map((step) => ({ at: step.at.getTime() === 0 ? "right away" : step.at.toISOString(), label: step.label, done: state.done[step.id] ?? null })),
146
+ };
147
+ })),
148
+ },
149
+ {
150
+ name: "check_plan",
151
+ description: "Pre-flight check: API key permissions, times, files, countdown icons, sale items and alerts. Lines with level \"bad\" must be fixed.",
152
+ inputSchema: { type: "object", properties: { game: GAME_ARG } },
153
+ run: async ({ game }) => {
154
+ const reports = [];
155
+ for (const config of planner.configs(game)) {
156
+ const ctx = planner.context(config);
157
+ reports.push(await preflight(config, ctx.cloud, config.notify ? process.env[config.notify.discordWebhookEnv] : undefined));
158
+ }
159
+ return text(reports);
160
+ },
161
+ },
162
+ {
163
+ name: "preview_icon",
164
+ description: "Render one countdown icon as players would see it. Stage is e.g. 24h, 1h, 5m, now or final.",
165
+ inputSchema: { type: "object", properties: { game: GAME_ARG, stage: { type: "string" } }, required: ["stage"] },
166
+ run: async ({ game, stage }) => {
167
+ const config = planner.configs(game)[0];
168
+ if (!config.countdown) {
169
+ return failure(`${config.name} has countdown icons turned off`);
170
+ }
171
+ const base = await sharp(await fetchCurrentIcon(config.universeId)).resize(ICON_SIZE, ICON_SIZE).png().toBuffer();
172
+ const image = await renderIcon(base, stage, config.countdown);
173
+ return { content: [{ type: "image", data: image.toString("base64"), mimeType: "image/png" }] };
174
+ },
175
+ },
176
+ {
177
+ name: "run_due_steps",
178
+ description: "Run every step that is due now, like one check of a running UpdateDay. Defaults to a dry run that only reports. Set dry_run false and confirm true to really do it.",
179
+ inputSchema: { type: "object", properties: { game: GAME_ARG, dry_run: { type: "boolean", default: true }, confirm: CONFIRM_ARG } },
180
+ run: async ({ game, dry_run = true, confirm = false }) => {
181
+ if (!dry_run && confirm !== true) {
182
+ return failure("This changes the live game. Call again with confirm: true.");
183
+ }
184
+ const lines = await captured(async () => {
185
+ for (const config of planner.configs(game)) {
186
+ const ctx = planner.context(config);
187
+ if (!dry_run && !ctx.cloud) {
188
+ throw new Error(`${config.name}: set ${config.apiKeyEnv} to an Open Cloud API key`);
189
+ }
190
+ const run = () => tick(buildSchedule(config), ctx, dry_run);
191
+ const left = dry_run ? await run() : await recording(config, run);
192
+ console.log(`${config.name}: ${left} step${left === 1 ? "" : "s"} left`);
193
+ }
194
+ });
195
+ return text(lines.join("\n") || "Nothing is due right now.");
196
+ },
197
+ },
198
+ {
199
+ name: "restore_everything",
200
+ description: "Undo: put every saved price and name back and end the countdown icons. Use when an update is cancelled.",
201
+ inputSchema: { type: "object", properties: { game: GAME_ARG, confirm: CONFIRM_ARG }, required: ["confirm"] },
202
+ run: async ({ game, confirm }) => {
203
+ if (confirm !== true) {
204
+ return failure("This changes the live game. Call again with confirm: true.");
205
+ }
206
+ const lines = await captured(async () => {
207
+ for (const config of planner.configs(game)) {
208
+ const ctx = planner.context(config);
209
+ await recording(config, async () => {
210
+ await restorePrices(ctx);
211
+ await endCountdown(ctx);
212
+ });
213
+ saveState(config.statePath, ctx.state);
214
+ }
215
+ });
216
+ return text(lines.join("\n") || "Nothing needed restoring.");
217
+ },
218
+ },
219
+ {
220
+ name: "get_history",
221
+ description: "What UpdateDay did and when, newest first.",
222
+ inputSchema: { type: "object", properties: { game: GAME_ARG, limit: { type: "number", default: HISTORY_LIMIT } } },
223
+ run: async ({ game, limit = HISTORY_LIMIT }) => {
224
+ const universeId = game ? planner.configs(game)[0].universeId : undefined;
225
+ return text(readHistory(planner.baseDir, universeId, limit));
226
+ },
227
+ },
228
+ {
229
+ name: "list_sale_items",
230
+ description: "List a game's passes and developer products with prices, to choose what goes on sale.",
231
+ inputSchema: { type: "object", properties: { game: GAME_ARG } },
232
+ run: async ({ game }) => {
233
+ const config = planner.configs(game)[0];
234
+ const ctx = planner.context(config);
235
+ if (!ctx.cloud) {
236
+ return failure(`Set ${config.apiKeyEnv} to an Open Cloud API key`);
237
+ }
238
+ return text([...(await ctx.cloud.listItems("gamePass")), ...(await ctx.cloud.listItems("developerProduct"))]);
239
+ },
240
+ },
241
+ ];
242
+ }
243
+ function reply(id, result) {
244
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
245
+ }
246
+ function replyError(id, code, message) {
247
+ process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
248
+ }
249
+ export async function handleMessage(list, message) {
250
+ const { id, method, params } = message;
251
+ if (id === undefined) {
252
+ return;
253
+ }
254
+ if (method === "initialize") {
255
+ reply(id, { protocolVersion: params?.protocolVersion ?? DEFAULT_PROTOCOL, capabilities: { tools: {} }, serverInfo: SERVER_INFO });
256
+ return;
257
+ }
258
+ if (method === "ping") {
259
+ reply(id, {});
260
+ return;
261
+ }
262
+ if (method === "tools/list") {
263
+ reply(id, { tools: list.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
264
+ return;
265
+ }
266
+ if (method === "tools/call") {
267
+ const tool = list.find((item) => item.name === params?.name);
268
+ if (!tool) {
269
+ replyError(id, -32602, `Unknown tool ${params?.name}`);
270
+ return;
271
+ }
272
+ try {
273
+ reply(id, await tool.run(params.arguments ?? {}));
274
+ }
275
+ catch (error) {
276
+ reply(id, failure(error instanceof Error ? error.message : String(error)));
277
+ }
278
+ return;
279
+ }
280
+ replyError(id, -32601, `Unknown method ${method}`);
281
+ }
282
+ export function startMcp(configPath) {
283
+ console.log = (...args) => process.stderr.write(`${args.join(" ")}\n`);
284
+ const list = tools(new Planner(configPath));
285
+ const input = createInterface({ input: process.stdin });
286
+ let queue = Promise.resolve();
287
+ input.on("line", (line) => {
288
+ if (!line.trim()) {
289
+ return;
290
+ }
291
+ let message;
292
+ try {
293
+ message = JSON.parse(line);
294
+ }
295
+ catch {
296
+ replyError(null, -32700, "Parse error");
297
+ return;
298
+ }
299
+ queue = queue.then(() => handleMessage(list, message));
300
+ });
301
+ return new Promise((done) => input.on("close", () => queue.then(done)));
302
+ }
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { iconMarks } from "./bundle.js";
5
+ import { renderIcon } from "./icons.js";
6
+ const LEDGER_SUFFIX = ".moderation.json";
7
+ const FINAL_MARK = "final";
8
+ function ledgerPath(config) {
9
+ return config.statePath.replace(/\.state\.json$/, "") + LEDGER_SUFFIX;
10
+ }
11
+ function readLedger(config) {
12
+ const path = ledgerPath(config);
13
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
14
+ }
15
+ function writeLedger(config, ledger) {
16
+ writeFileSync(ledgerPath(config), JSON.stringify(ledger, null, "\t"));
17
+ }
18
+ function artStamp(config, mark) {
19
+ const dir = config.countdown?.overlayDir;
20
+ if (!dir) {
21
+ return "";
22
+ }
23
+ return [mark, "default"].map((name) => {
24
+ const path = join(dir, `${name}.png`);
25
+ return existsSync(path) ? String(statSync(path).mtimeMs) : "";
26
+ }).join("|");
27
+ }
28
+ export function designKey(config, base, mark) {
29
+ const hash = createHash("sha1");
30
+ hash.update(base);
31
+ hash.update(JSON.stringify(config.countdown));
32
+ hash.update(mark);
33
+ hash.update(artStamp(config, mark));
34
+ return hash.digest("hex");
35
+ }
36
+ function checkedMarks(config) {
37
+ const marks = iconMarks(config.countdown);
38
+ return config.countdown?.newIcon ? marks : marks.filter((mark) => mark !== FINAL_MARK);
39
+ }
40
+ async function readState(cloud, assetId) {
41
+ const asset = await cloud.request("GET", `/assets/v1/assets/${assetId}?readMask=moderationResult`, "read asset");
42
+ const state = String(asset?.moderationResult?.moderationState ?? "unknown");
43
+ return (["Approved", "Reviewing", "Rejected"].includes(state) ? state : "unknown");
44
+ }
45
+ export async function moderationStatus(config, cloud, base) {
46
+ if (!config.countdown) {
47
+ return [];
48
+ }
49
+ const ledger = readLedger(config);
50
+ let changed = false;
51
+ const result = [];
52
+ for (const mark of checkedMarks(config)) {
53
+ const entry = ledger[mark];
54
+ if (!entry || entry.design !== designKey(config, base, mark)) {
55
+ result.push({ mark, state: "unchecked", assetId: null, checkedAt: null });
56
+ continue;
57
+ }
58
+ if (cloud && (entry.state === "Reviewing" || entry.state === "unknown")) {
59
+ entry.state = await readState(cloud, entry.assetId);
60
+ entry.checkedAt = new Date().toISOString();
61
+ changed = true;
62
+ }
63
+ result.push({ mark, state: entry.state, assetId: entry.assetId, checkedAt: entry.checkedAt });
64
+ }
65
+ if (changed) {
66
+ writeLedger(config, ledger);
67
+ }
68
+ return result;
69
+ }
70
+ export async function submitForModeration(config, cloud, base) {
71
+ const ledger = readLedger(config);
72
+ let sent = 0;
73
+ for (const mark of checkedMarks(config)) {
74
+ const design = designKey(config, base, mark);
75
+ const entry = ledger[mark];
76
+ if (entry && entry.design === design && entry.state !== "Rejected") {
77
+ continue;
78
+ }
79
+ const image = await renderIcon(base, mark, config.countdown);
80
+ const assetId = await cloud.uploadImage(new Blob([new Uint8Array(image)], { type: "image/png" }), `UpdateDay ${config.name} ${mark}`);
81
+ ledger[mark] = { design, assetId, state: "Reviewing", checkedAt: new Date().toISOString() };
82
+ writeLedger(config, ledger);
83
+ sent++;
84
+ }
85
+ return sent;
86
+ }