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/server.js ADDED
@@ -0,0 +1,929 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { createServer } from "node:http";
11
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import sharp from "sharp";
14
+ import { activeMarks, flattenSingleGame, nextOccurrence, parseConfigs, showsNow } from "./config.js";
15
+ import { iconZip, kitZip } from "./bundle.js";
16
+ import { readHistory, recording } from "./history.js";
17
+ import { discordNotifier, isWebhookUrl, postDiscord } from "./notify.js";
18
+ import { moderationStatus, submitForModeration } from "./moderation.js";
19
+ import { preflight } from "./preflight.js";
20
+ import { fetchCurrentIcon, HOURGLASS_STYLES, ICON_SIZE, renderIcon, stylePath } from "./icons.js";
21
+ import { checkKey } from "./doctor.js";
22
+ import { pageReport } from "./gamePage.js";
23
+ import { cancelGift, DEFAULT_GIFT_STORE, pendingGifts, sendGift } from "./gifts.js";
24
+ import { entryKey, listBackups, listDataStores, lookupPlayer, readBackup, readEntry, saveEntry } from "./playerData.js";
25
+ import { OpenCloud } from "./openCloud.js";
26
+ import { endCountdown, restorePrices, tick } from "./runner.js";
27
+ import { buildSchedule } from "./schedule.js";
28
+ import { loadState, saveState } from "./state.js";
29
+ const HOST = "127.0.0.1";
30
+ const UI_FILE = resolve(dirname(fileURLToPath(import.meta.url)), "../ui/index.html");
31
+ const UPLOAD_DIR = "updateday-images";
32
+ const MAX_BODY_BYTES = 15 * 1024 * 1024;
33
+ const ICON_CACHE_MS = 10 * 60 * 1000;
34
+ const WATCH_INTERVAL_MS = 30 * 1000;
35
+ const LOG_LIMIT = 500;
36
+ const RENDER_CACHE_LIMIT = 300;
37
+ const ITEM_CACHE_MS = 60 * 1000;
38
+ const ASSET_THUMBS_URL = "https://thumbnails.roblox.com/v1/assets";
39
+ const ART_DIR = "updateday-art";
40
+ const ART_MARK = /^(\d+[smhd]|now|default)$/;
41
+ const ZIP_TYPE = "application/zip";
42
+ function emptyDraft() {
43
+ return { updateAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), games: [] };
44
+ }
45
+ function toMulti(raw) {
46
+ if (Array.isArray(raw.games)) {
47
+ return raw;
48
+ }
49
+ const { universeId, name, apiKeyEnv, ...shared } = raw;
50
+ const game = { universeId };
51
+ if (name !== undefined) {
52
+ game.name = name;
53
+ }
54
+ if (apiKeyEnv !== undefined) {
55
+ game.apiKeyEnv = apiKeyEnv;
56
+ }
57
+ return { ...shared, games: universeId === undefined ? [] : [game] };
58
+ }
59
+ function readDraft(configPath) {
60
+ if (!existsSync(configPath)) {
61
+ return emptyDraft();
62
+ }
63
+ return toMulti(JSON.parse(readFileSync(configPath, "utf8")));
64
+ }
65
+ function applyDraft(server, draft) {
66
+ server.draft = draft;
67
+ server.revision++;
68
+ try {
69
+ const games = draft.games ?? [];
70
+ const complete = games.filter(isComplete);
71
+ const asSaved = flattenSingleGame({ ...draft, games: complete });
72
+ const parsed = complete.length ? parseConfigs(asSaved, server.baseDir) : [];
73
+ server.draftConfigs = games.map((game) => (isComplete(game) ? parsed[complete.indexOf(game)] : null));
74
+ server.draftError = null;
75
+ }
76
+ catch (error) {
77
+ server.draftConfigs = [];
78
+ server.draftError = error instanceof Error ? error.message : String(error);
79
+ }
80
+ }
81
+ function isComplete(game) {
82
+ return Number.isInteger(game?.universeId);
83
+ }
84
+ function incompleteGameError(draft) {
85
+ const games = draft.games ?? [];
86
+ if (games.length === 0) {
87
+ return "Add at least one game";
88
+ }
89
+ const index = games.findIndex((game) => !isComplete(game));
90
+ return index >= 0 ? `${games[index].name || `Game ${index + 1}`} needs a universe ID` : null;
91
+ }
92
+ function pushLog(server, line) {
93
+ server.log.push(line);
94
+ if (server.log.length > LOG_LIMIT) {
95
+ server.log.splice(0, server.log.length - LOG_LIMIT);
96
+ }
97
+ }
98
+ function captureConsole(server) {
99
+ const original = { log: console.log, error: console.error };
100
+ console.log = (...args) => {
101
+ pushLog(server, args.join(" "));
102
+ original.log(...args);
103
+ };
104
+ console.error = (...args) => {
105
+ pushLog(server, `ERROR ${args.join(" ")}`);
106
+ original.error(...args);
107
+ };
108
+ }
109
+ function apiKeyFor(server, config) {
110
+ return server.sessionKeys[config.apiKeyEnv] ?? process.env[config.apiKeyEnv];
111
+ }
112
+ function webhookFor(server, config) {
113
+ if (!config.notify) {
114
+ return undefined;
115
+ }
116
+ return server.sessionKeys[config.notify.discordWebhookEnv] ?? process.env[config.notify.discordWebhookEnv];
117
+ }
118
+ function send(res, status, body, type = "application/json") {
119
+ const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
120
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
121
+ res.end(payload);
122
+ }
123
+ function readBody(req) {
124
+ return new Promise((resolveBody, reject) => {
125
+ const chunks = [];
126
+ let size = 0;
127
+ req.on("data", (chunk) => {
128
+ size += chunk.length;
129
+ if (size > MAX_BODY_BYTES) {
130
+ reject(new Error("Upload too large"));
131
+ req.destroy();
132
+ return;
133
+ }
134
+ chunks.push(chunk);
135
+ });
136
+ req.on("end", () => resolveBody(Buffer.concat(chunks)));
137
+ req.on("error", reject);
138
+ });
139
+ }
140
+ function isLocalRequest(req, port) {
141
+ const allowed = [`${HOST}:${port}`, `localhost:${port}`];
142
+ if (!allowed.includes(req.headers.host ?? "")) {
143
+ return false;
144
+ }
145
+ const origin = req.headers.origin;
146
+ return !origin || allowed.some((host) => origin === `http://${host}`);
147
+ }
148
+ async function baseIcon(server, config) {
149
+ if (existsSync(config.snapshotPath)) {
150
+ return readFileSync(config.snapshotPath);
151
+ }
152
+ const cached = server.iconCache.get(config.universeId);
153
+ if (cached && Date.now() - cached.at < ICON_CACHE_MS) {
154
+ return cached.image;
155
+ }
156
+ const image = await sharp(await fetchCurrentIcon(config.universeId)).resize(ICON_SIZE, ICON_SIZE).png().toBuffer();
157
+ server.iconCache.set(config.universeId, { at: Date.now(), image });
158
+ return image;
159
+ }
160
+ function describeStep(step, state) {
161
+ const mark = step.action.type === "icon" ? step.action.mark : null;
162
+ return {
163
+ id: step.id,
164
+ at: step.at.getTime() === 0 ? null : step.at.toISOString(),
165
+ label: step.label,
166
+ type: step.action.type,
167
+ mark,
168
+ done: state.done[step.id] ?? null,
169
+ };
170
+ }
171
+ function scheduleView(server) {
172
+ return server.draftConfigs.map((config) => {
173
+ if (!config) {
174
+ return null;
175
+ }
176
+ const state = loadState(config.statePath, config.updateAt);
177
+ const steps = buildSchedule(config).map((step) => describeStep(step, state));
178
+ return {
179
+ name: config.name,
180
+ universeId: config.universeId,
181
+ updateAt: config.updateAt,
182
+ apiKeyEnv: config.apiKeyEnv,
183
+ hasKey: Boolean(apiKeyFor(server, config)),
184
+ repeat: config.repeat ?? null,
185
+ nextUpdateAt: nextOccurrence(config)?.toISOString() ?? null,
186
+ webhookEnv: config.notify?.discordWebhookEnv ?? null,
187
+ hasWebhook: Boolean(webhookFor(server, config)),
188
+ marks: config.countdown ? activeMarks(config.countdown) : [],
189
+ showsNow: config.countdown ? showsNow(config.countdown) : false,
190
+ prices: state.prices,
191
+ reminders: (state.reminders ?? []).map((text) => {
192
+ const file = text.slice(text.lastIndexOf(": ") + 2);
193
+ return { text: text.slice(0, text.lastIndexOf(": ")), file: relative(server.baseDir, file).replace(/\\/g, "/") };
194
+ }),
195
+ swap: state.swap ?? null,
196
+ steps,
197
+ };
198
+ });
199
+ }
200
+ async function handleIcon(server, url, res) {
201
+ const config = server.draftConfigs[Number(url.searchParams.get("game"))];
202
+ const mark = url.searchParams.get("mark") ?? "now";
203
+ if (!config?.countdown) {
204
+ send(res, 404, { error: "No countdown for that game" });
205
+ return;
206
+ }
207
+ const base = await baseIcon(server, config);
208
+ if (url.searchParams.get("raw")) {
209
+ send(res, 200, base, "image/png");
210
+ return;
211
+ }
212
+ const key = `${config.universeId}|${mark}|${base.length}|${JSON.stringify(config.countdown)}`;
213
+ let image = server.renderCache.get(key);
214
+ if (!image) {
215
+ image = await renderIcon(base, mark, config.countdown);
216
+ if (server.renderCache.size >= RENDER_CACHE_LIMIT) {
217
+ server.renderCache.clear();
218
+ }
219
+ server.renderCache.set(key, image);
220
+ }
221
+ send(res, 200, image, "image/png");
222
+ }
223
+ async function handleUpload(server, url, req, res) {
224
+ const universeId = Number(url.searchParams.get("universeId")) || "game";
225
+ const body = await readBody(req);
226
+ const png = await sharp(body).resize(ICON_SIZE, ICON_SIZE, { fit: "cover" }).png().toBuffer();
227
+ const folder = join(server.baseDir, UPLOAD_DIR);
228
+ mkdirSync(folder, { recursive: true });
229
+ const fileName = `${universeId}-new-icon-${Date.now()}.png`;
230
+ writeFileSync(join(folder, fileName), png);
231
+ send(res, 200, { path: relative(server.baseDir, join(folder, fileName)).replace(/\\/g, "/") });
232
+ }
233
+ function handleFile(server, url, res) {
234
+ const path = resolve(server.baseDir, url.searchParams.get("path") ?? "");
235
+ const inside = relative(server.baseDir, path);
236
+ if (!inside || inside.startsWith("..") || isAbsolute(inside) || !/\.(png|jpe?g|webp)$/i.test(path) || !existsSync(path)) {
237
+ send(res, 404, { error: "Not found" });
238
+ return;
239
+ }
240
+ send(res, 200, readFileSync(path), "image/png");
241
+ }
242
+ function isPlaceholderName(name) {
243
+ return !name || /^\[.*\]$/.test(name.trim());
244
+ }
245
+ async function lookupGame(query) {
246
+ const placeMatch = /games\/(\d+)/.exec(query) ?? /^(\d+)$/.exec(query.trim());
247
+ if (!placeMatch) {
248
+ throw new Error("Paste a roblox.com/games/... link or a place ID");
249
+ }
250
+ const universe = await fetch(`https://apis.roblox.com/universes/v1/places/${placeMatch[1]}/universe`);
251
+ const universeId = (await universe.json()).universeId;
252
+ if (!Number.isInteger(universeId)) {
253
+ throw new Error("No game found for that place");
254
+ }
255
+ const details = await fetch(`https://games.roblox.com/v1/games?universeIds=${universeId}`);
256
+ const name = details.ok ? (await details.json()).data?.[0]?.name ?? null : null;
257
+ return { universeId, name: isPlaceholderName(name) ? null : name };
258
+ }
259
+ async function iconUrls(assetIds) {
260
+ const urls = new Map();
261
+ for (let i = 0; i < assetIds.length; i += 100) {
262
+ const batch = assetIds.slice(i, i + 100);
263
+ const response = await fetch(`${ASSET_THUMBS_URL}?assetIds=${batch.join(",")}&size=150x150&format=Png`);
264
+ if (!response.ok) {
265
+ continue;
266
+ }
267
+ for (const entry of (await response.json()).data ?? []) {
268
+ if (entry.imageUrl) {
269
+ urls.set(entry.targetId, entry.imageUrl);
270
+ }
271
+ }
272
+ }
273
+ return urls;
274
+ }
275
+ async function handleItems(server, url, res) {
276
+ const config = server.draftConfigs[Number(url.searchParams.get("game"))];
277
+ if (!config) {
278
+ send(res, 404, { error: "Pick a game with a universe ID first" });
279
+ return;
280
+ }
281
+ const apiKey = apiKeyFor(server, config);
282
+ if (!apiKey) {
283
+ send(res, 400, { error: `Add the API key (${config.apiKeyEnv}) to load this game's passes and products` });
284
+ return;
285
+ }
286
+ const cached = server.itemCache.get(config.universeId);
287
+ if (cached && Date.now() - cached.at < ITEM_CACHE_MS && !url.searchParams.get("refresh")) {
288
+ send(res, 200, { items: cached.items });
289
+ return;
290
+ }
291
+ const cloud = new OpenCloud(apiKey, config.universeId);
292
+ const items = [...(await cloud.listItems("gamePass")), ...(await cloud.listItems("developerProduct"))];
293
+ const icons = await iconUrls(items.flatMap((item) => (item.iconAssetId ? [item.iconAssetId] : [])));
294
+ const view = items.map((item) => ({ ...item, iconUrl: item.iconAssetId ? icons.get(item.iconAssetId) ?? null : null }));
295
+ server.itemCache.set(config.universeId, { at: Date.now(), items: view });
296
+ send(res, 200, { items: view });
297
+ }
298
+ function gameCloud(server, url) {
299
+ const config = server.draftConfigs[Number(url.searchParams.get("game"))];
300
+ if (!config) {
301
+ return null;
302
+ }
303
+ const apiKey = apiKeyFor(server, config);
304
+ return { config, cloud: apiKey ? new OpenCloud(apiKey, config.universeId) : null };
305
+ }
306
+ async function handlePage(server, url, res) {
307
+ const target = gameCloud(server, url);
308
+ if (!target) {
309
+ send(res, 404, { error: "Pick a game with a universe ID first" });
310
+ return;
311
+ }
312
+ send(res, 200, await pageReport(target.config, target.cloud));
313
+ }
314
+ const EVENT_FIELDS = ["title", "subtitle", "description", "startTime", "endTime", "visibility", "categories", "thumbnails", "tagline"];
315
+ function eventBody(raw) {
316
+ const body = {};
317
+ for (const field of EVENT_FIELDS) {
318
+ if (raw[field] !== undefined) {
319
+ body[field] = raw[field];
320
+ }
321
+ }
322
+ return body;
323
+ }
324
+ async function handleEvents(server, method, url, req, res) {
325
+ const target = gameCloud(server, url);
326
+ if (!target?.cloud) {
327
+ send(res, 400, { error: target ? `Add the API key (${target.config.apiKeyEnv}) to manage events` : "Pick a game first" });
328
+ return;
329
+ }
330
+ const { cloud, config } = target;
331
+ const id = url.searchParams.get("id");
332
+ if (method === "GET") {
333
+ const json = await cloud.request("GET", `/virtual-events/v3/universes/${config.universeId}/game-events`, "list events");
334
+ send(res, 200, { events: json?.gameEvents ?? [] });
335
+ return;
336
+ }
337
+ if (method === "DELETE" && id) {
338
+ await cloud.request("DELETE", `/virtual-events/v3/game-events/${encodeURIComponent(id)}`, "delete event");
339
+ send(res, 200, { ok: true });
340
+ return;
341
+ }
342
+ const body = eventBody(JSON.parse((await readBody(req)).toString("utf8")));
343
+ if (method === "PATCH" && id) {
344
+ send(res, 200, await cloud.json("PATCH", `/virtual-events/v3/game-events/${encodeURIComponent(id)}`, "update event", body));
345
+ return;
346
+ }
347
+ if (method === "POST") {
348
+ send(res, 200, await cloud.json("POST", `/virtual-events/v3/universes/${config.universeId}/game-events`, "create event", body));
349
+ return;
350
+ }
351
+ send(res, 400, { error: "Unsupported event request" });
352
+ }
353
+ async function jsonBody(req) {
354
+ const text = (await readBody(req)).toString("utf8");
355
+ return text ? JSON.parse(text) : {};
356
+ }
357
+ function needCloud(server, url, res, what) {
358
+ const target = gameCloud(server, url);
359
+ if (!target?.cloud) {
360
+ send(res, 400, { error: target ? `Add the API key (${target.config.apiKeyEnv}) to ${what}` : "Pick a game first" });
361
+ return null;
362
+ }
363
+ return { config: target.config, cloud: target.cloud };
364
+ }
365
+ async function handlePlayer(server, url, res) {
366
+ const player = await lookupPlayer(url.searchParams.get("q") ?? "");
367
+ const target = gameCloud(server, url);
368
+ const result = { player };
369
+ if (target?.cloud && target.config.playerData) {
370
+ const key = entryKey(target.config.playerData.keyFormat, player.id);
371
+ result.dataKey = key;
372
+ result.entry = await readEntry(target.cloud, target.config.playerData.dataStore, key);
373
+ result.backups = listBackups(server.baseDir, target.config.universeId, target.config.playerData.dataStore, key);
374
+ }
375
+ if (target?.cloud) {
376
+ result.gifts = await pendingGifts(target.cloud, target.config.gifts?.dataStore ?? DEFAULT_GIFT_STORE, player.id);
377
+ }
378
+ send(res, 200, result);
379
+ }
380
+ async function handleEntry(server, method, url, req, res) {
381
+ const target = needCloud(server, url, res, "edit player data");
382
+ if (!target) {
383
+ return;
384
+ }
385
+ const dataStore = url.searchParams.get("ds") ?? "";
386
+ const key = url.searchParams.get("key") ?? "";
387
+ if (!dataStore || !key) {
388
+ send(res, 400, { error: "Pick a data store and key" });
389
+ return;
390
+ }
391
+ if (method === "GET") {
392
+ send(res, 200, {
393
+ entry: await readEntry(target.cloud, dataStore, key),
394
+ backups: listBackups(server.baseDir, target.config.universeId, dataStore, key),
395
+ });
396
+ return;
397
+ }
398
+ const body = await jsonBody(req);
399
+ if (method === "PUT") {
400
+ const entry = await saveEntry(target.cloud, server.baseDir, dataStore, key, body.value, body.etag ?? null, "edit from UpdateDay");
401
+ send(res, 200, { entry, backups: listBackups(server.baseDir, target.config.universeId, dataStore, key) });
402
+ return;
403
+ }
404
+ if (method === "POST" && body.restore) {
405
+ const value = readBackup(server.baseDir, target.config.universeId, dataStore, key, String(body.restore));
406
+ const entry = await saveEntry(target.cloud, server.baseDir, dataStore, key, value, null, `restore ${body.restore}`);
407
+ send(res, 200, { entry, backups: listBackups(server.baseDir, target.config.universeId, dataStore, key) });
408
+ return;
409
+ }
410
+ send(res, 400, { error: "Unsupported entry request" });
411
+ }
412
+ async function handleGifts(server, method, url, req, res) {
413
+ const target = needCloud(server, url, res, "send gifts");
414
+ if (!target) {
415
+ return;
416
+ }
417
+ const userId = Number(url.searchParams.get("userId"));
418
+ if (!Number.isInteger(userId) || userId <= 0) {
419
+ send(res, 400, { error: "Look up a player first" });
420
+ return;
421
+ }
422
+ const store = target.config.gifts?.dataStore ?? DEFAULT_GIFT_STORE;
423
+ if (method === "GET") {
424
+ send(res, 200, { gifts: await pendingGifts(target.cloud, store, userId) });
425
+ return;
426
+ }
427
+ if (method === "POST") {
428
+ const gift = await sendGift(target.cloud, server.baseDir, store, userId, await jsonBody(req));
429
+ send(res, 200, { gift, gifts: await pendingGifts(target.cloud, store, userId) });
430
+ return;
431
+ }
432
+ if (method === "DELETE") {
433
+ send(res, 200, { gifts: await cancelGift(target.cloud, server.baseDir, store, userId, url.searchParams.get("id") ?? "") });
434
+ return;
435
+ }
436
+ send(res, 400, { error: "Unsupported gift request" });
437
+ }
438
+ function savedConfigs(server) {
439
+ const raw = JSON.parse(readFileSync(server.configPath, "utf8"));
440
+ return parseConfigs(raw, server.baseDir);
441
+ }
442
+ function makeContext(server, config) {
443
+ const apiKey = apiKeyFor(server, config);
444
+ return {
445
+ config,
446
+ state: loadState(config.statePath, config.updateAt),
447
+ cloud: apiKey ? new OpenCloud(apiKey, config.universeId) : null,
448
+ now: new Date(),
449
+ notify: discordNotifier(config, webhookFor(server, config)),
450
+ };
451
+ }
452
+ async function tickAll(server, configs, dryRun) {
453
+ let remaining = 0;
454
+ for (const config of configs) {
455
+ if (configs.length > 1) {
456
+ console.log(`-- ${config.name}`);
457
+ }
458
+ try {
459
+ const ctx = makeContext(server, config);
460
+ if (!dryRun && !ctx.cloud) {
461
+ throw new Error(`no API key (${config.apiKeyEnv})`);
462
+ }
463
+ const run = () => tick(buildSchedule(config), ctx, dryRun);
464
+ const left = dryRun ? await run() : await recording(config, run);
465
+ remaining += left;
466
+ }
467
+ catch (error) {
468
+ console.error(`${config.name}: ${error instanceof Error ? error.message : error}`);
469
+ remaining++;
470
+ }
471
+ }
472
+ return remaining;
473
+ }
474
+ async function watchLoop(server) {
475
+ server.mode = "watching";
476
+ server.stopRequested = false;
477
+ console.log("Started. Checking every 30 seconds.");
478
+ let waitingLogged = false;
479
+ try {
480
+ while (!server.stopRequested) {
481
+ const configs = savedConfigs(server);
482
+ const remaining = await tickAll(server, configs, false);
483
+ const repeating = configs.filter((config) => config.repeat);
484
+ if (remaining === 0 && repeating.length === 0) {
485
+ console.log("Every schedule is complete.");
486
+ break;
487
+ }
488
+ if (remaining === 0 && !waitingLogged) {
489
+ const next = repeating.map((config) => nextOccurrence(config)).filter((date) => date !== null).sort((a, b) => a.getTime() - b.getTime())[0];
490
+ if (!next) {
491
+ console.log("Every schedule is complete.");
492
+ break;
493
+ }
494
+ console.log(`This update day is done. Waiting for the next one (${next.toUTCString()}).`);
495
+ waitingLogged = true;
496
+ }
497
+ if (remaining > 0) {
498
+ waitingLogged = false;
499
+ }
500
+ for (let waited = 0; waited < WATCH_INTERVAL_MS && !server.stopRequested; waited += 1000) {
501
+ await new Promise((done) => setTimeout(done, 1000));
502
+ }
503
+ }
504
+ }
505
+ finally {
506
+ server.mode = "idle";
507
+ console.log("Stopped.");
508
+ }
509
+ }
510
+ const EXPERIMENTS_URL = new URL(`./experiments${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url).href;
511
+ async function runSwapExperiment(server, swapTo, holdMs, approvalWaitMs) {
512
+ const config = savedConfigs(server)[0];
513
+ const ctx = makeContext(server, config);
514
+ if (!ctx.cloud) {
515
+ console.error(`no API key (${config.apiKeyEnv})`);
516
+ return;
517
+ }
518
+ server.mode = "experimenting";
519
+ server.stopRequested = false;
520
+ try {
521
+ const base = await baseIcon(server, config);
522
+ const style = { ...(config.countdown ?? parseConfigs({ universeId: 1, updateAt: config.updateAt }, server.baseDir)[0].countdown), skip: [] };
523
+ const icon = await renderIcon(base, "10m", style);
524
+ const { languageSwapExperiment } = await import(__rewriteRelativeImportExtension(`${EXPERIMENTS_URL}?v=${Date.now()}`));
525
+ await languageSwapExperiment({
526
+ cloud: ctx.cloud,
527
+ universeId: config.universeId,
528
+ icon: new Blob([new Uint8Array(icon)], { type: "image/png" }),
529
+ holdMs,
530
+ approvalWaitMs,
531
+ swapTo,
532
+ log: (line) => console.log(line),
533
+ shouldStop: () => server.stopRequested,
534
+ });
535
+ }
536
+ catch (error) {
537
+ console.error(`[swap] ${error instanceof Error ? error.message : error}`);
538
+ }
539
+ finally {
540
+ server.mode = "idle";
541
+ console.log("[swap] experiment finished");
542
+ }
543
+ }
544
+ async function runExperiment(server, params) {
545
+ const config = savedConfigs(server)[0];
546
+ const ctx = makeContext(server, config);
547
+ const name = params.get("name") ?? "";
548
+ if (!ctx.cloud) {
549
+ console.error(`no API key (${config.apiKeyEnv})`);
550
+ return;
551
+ }
552
+ server.mode = "experimenting";
553
+ server.stopRequested = false;
554
+ try {
555
+ const experiments = await import(__rewriteRelativeImportExtension(`${EXPERIMENTS_URL}?v=${Date.now()}`));
556
+ if (typeof experiments[name] !== "function") {
557
+ throw new Error(`No experiment called "${name}"`);
558
+ }
559
+ await experiments[name]({
560
+ cloud: ctx.cloud,
561
+ universeId: config.universeId,
562
+ params,
563
+ log: (line) => console.log(line),
564
+ shouldStop: () => server.stopRequested,
565
+ });
566
+ }
567
+ catch (error) {
568
+ console.error(`[${name}] ${error instanceof Error ? error.message : error}`);
569
+ }
570
+ finally {
571
+ server.mode = "idle";
572
+ console.log(`[${name}] experiment finished`);
573
+ }
574
+ }
575
+ async function handleRun(server, action, query, res) {
576
+ if (action === "stop") {
577
+ server.stopRequested = true;
578
+ send(res, 200, { ok: true });
579
+ return;
580
+ }
581
+ if (server.mode !== "idle") {
582
+ send(res, 409, { error: "Already running. Stop it first." });
583
+ return;
584
+ }
585
+ if (action === "swap-experiment") {
586
+ const params = new URL(`http://x/?${query}`).searchParams;
587
+ const holdMs = (Number(params.get("hold")) || 180) * 1000;
588
+ const approvalWaitMs = (Number(params.get("wait")) || 900) * 1000;
589
+ runSwapExperiment(server, params.get("swapTo") || "de", holdMs, approvalWaitMs);
590
+ send(res, 200, { ok: true });
591
+ return;
592
+ }
593
+ if (action === "experiment") {
594
+ runExperiment(server, new URLSearchParams(query));
595
+ send(res, 200, { ok: true });
596
+ return;
597
+ }
598
+ if (action === "start") {
599
+ watchLoop(server);
600
+ send(res, 200, { ok: true });
601
+ return;
602
+ }
603
+ if (action === "dryrun") {
604
+ console.log("Dry run: what would happen right now");
605
+ await tickAll(server, savedConfigs(server), true);
606
+ send(res, 200, { ok: true });
607
+ return;
608
+ }
609
+ if (action === "restore") {
610
+ for (const config of savedConfigs(server)) {
611
+ const ctx = makeContext(server, config);
612
+ if (!ctx.cloud) {
613
+ console.error(`${config.name}: no API key (${config.apiKeyEnv})`);
614
+ continue;
615
+ }
616
+ await recording(config, async () => {
617
+ await restorePrices(ctx);
618
+ await endCountdown(ctx);
619
+ });
620
+ saveState(config.statePath, ctx.state);
621
+ }
622
+ send(res, 200, { ok: true });
623
+ return;
624
+ }
625
+ send(res, 400, { error: `Unknown action ${action}` });
626
+ }
627
+ async function handlePreflight(server, url, res) {
628
+ const picked = url.searchParams.get("game");
629
+ const configs = picked === null ? server.draftConfigs : [server.draftConfigs[Number(picked)]];
630
+ const reports = [];
631
+ for (const config of configs) {
632
+ if (!config) {
633
+ continue;
634
+ }
635
+ const apiKey = apiKeyFor(server, config);
636
+ reports.push(await preflight(config, apiKey ? new OpenCloud(apiKey, config.universeId) : null, webhookFor(server, config)));
637
+ }
638
+ send(res, 200, { reports, error: server.draftError });
639
+ }
640
+ function fileSafe(text) {
641
+ return text.replace(/[^\w-]+/g, "_").replace(/^_+|_+$/g, "") || "game";
642
+ }
643
+ async function handleIconZip(server, url, res) {
644
+ const config = server.draftConfigs[Number(url.searchParams.get("game"))];
645
+ if (!config?.countdown) {
646
+ send(res, 404, { error: "No countdown for that game" });
647
+ return;
648
+ }
649
+ const data = await iconZip(config, await baseIcon(server, config), url.searchParams.get("languages") === "1");
650
+ res.writeHead(200, {
651
+ "content-type": ZIP_TYPE,
652
+ "content-disposition": `attachment; filename="${fileSafe(config.name)}-countdown-icons.zip"`,
653
+ "cache-control": "no-store",
654
+ });
655
+ res.end(data);
656
+ }
657
+ function artFolder(server) {
658
+ return join(server.baseDir, ART_DIR);
659
+ }
660
+ async function handleArt(server, method, url, req, res) {
661
+ const folder = artFolder(server);
662
+ if (method === "GET") {
663
+ const files = existsSync(folder) ? readdirSync(folder).filter((name) => name.endsWith(".png")).map((name) => name.slice(0, -4)) : [];
664
+ send(res, 200, { dir: ART_DIR, marks: files });
665
+ return;
666
+ }
667
+ const mark = url.searchParams.get("mark") ?? "";
668
+ if (!ART_MARK.test(mark)) {
669
+ send(res, 400, { error: "Pick a stage for this art" });
670
+ return;
671
+ }
672
+ const path = join(folder, `${mark}.png`);
673
+ if (method === "DELETE") {
674
+ rmSync(path, { force: true });
675
+ }
676
+ else if (method === "POST") {
677
+ const art = await sharp(await readBody(req)).resize(ICON_SIZE, ICON_SIZE, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();
678
+ mkdirSync(folder, { recursive: true });
679
+ writeFileSync(path, art);
680
+ }
681
+ else {
682
+ send(res, 400, { error: "Unsupported art request" });
683
+ return;
684
+ }
685
+ server.renderCache.clear();
686
+ send(res, 200, { ok: true, dir: ART_DIR });
687
+ }
688
+ async function handleModeration(server, method, url, res) {
689
+ const target = gameCloud(server, url);
690
+ if (!target?.config.countdown) {
691
+ send(res, 404, { error: "No countdown for that game" });
692
+ return;
693
+ }
694
+ const base = await baseIcon(server, target.config);
695
+ if (method === "POST") {
696
+ if (!target.cloud) {
697
+ send(res, 400, { error: `Add the API key (${target.config.apiKeyEnv}) to check icons with moderation` });
698
+ return;
699
+ }
700
+ const sent = await submitForModeration(target.config, target.cloud, base);
701
+ send(res, 200, { sent, stages: await moderationStatus(target.config, target.cloud, base) });
702
+ return;
703
+ }
704
+ send(res, 200, { stages: await moderationStatus(target.config, target.cloud, base) });
705
+ }
706
+ async function handleNotifyTest(server, url, res) {
707
+ const config = server.draftConfigs[Number(url.searchParams.get("game"))] ?? server.draftConfigs.find(Boolean);
708
+ if (!config?.notify) {
709
+ send(res, 400, { error: "Turn on Discord alerts first" });
710
+ return;
711
+ }
712
+ const webhook = webhookFor(server, config);
713
+ if (!webhook) {
714
+ send(res, 400, { error: "Paste the Discord webhook link first" });
715
+ return;
716
+ }
717
+ await postDiscord(webhook, { event: "finished", title: "UpdateDay test", text: "Alerts for your update days will arrive here." }, config.notify.mention);
718
+ send(res, 200, { ok: true });
719
+ }
720
+ async function route(server, req, res) {
721
+ const url = new URL(req.url ?? "/", `http://${HOST}:${server.port}`);
722
+ const method = req.method ?? "GET";
723
+ if (method === "GET" && url.pathname === "/") {
724
+ send(res, 200, readFileSync(UI_FILE), "text/html; charset=utf-8");
725
+ return;
726
+ }
727
+ if (method === "GET" && /^\/ui\/[\w-]+\.js$/.test(url.pathname)) {
728
+ const file = resolve(dirname(UI_FILE), url.pathname.slice("/ui/".length));
729
+ if (!existsSync(file)) {
730
+ send(res, 404, { error: "Not found" });
731
+ return;
732
+ }
733
+ send(res, 200, readFileSync(file), "text/javascript; charset=utf-8");
734
+ return;
735
+ }
736
+ if (method === "GET" && url.pathname === "/api/draft") {
737
+ send(res, 200, { draft: server.draft, error: server.draftError, revision: server.revision, configPath: server.configPath });
738
+ return;
739
+ }
740
+ if (method === "PUT" && url.pathname === "/api/draft") {
741
+ applyDraft(server, toMulti(JSON.parse((await readBody(req)).toString("utf8"))));
742
+ send(res, 200, { error: server.draftError, revision: server.revision });
743
+ return;
744
+ }
745
+ if (method === "POST" && url.pathname === "/api/save") {
746
+ const error = server.draftError ?? incompleteGameError(server.draft);
747
+ if (error) {
748
+ send(res, 400, { error });
749
+ return;
750
+ }
751
+ writeFileSync(server.configPath, JSON.stringify(flattenSingleGame(server.draft), null, "\t"));
752
+ send(res, 200, { ok: true, running: server.mode !== "idle" });
753
+ return;
754
+ }
755
+ if (method === "GET" && url.pathname === "/api/schedule") {
756
+ send(res, 200, { games: scheduleView(server), error: server.draftError, revision: server.revision });
757
+ return;
758
+ }
759
+ if (method === "GET" && url.pathname === "/api/doctor") {
760
+ const target = needCloud(server, url, res, "check it");
761
+ if (target) {
762
+ send(res, 200, { probes: await checkKey(target.cloud, target.config) });
763
+ }
764
+ return;
765
+ }
766
+ if (method === "GET" && url.pathname === "/api/player") {
767
+ await handlePlayer(server, url, res);
768
+ return;
769
+ }
770
+ if (method === "GET" && url.pathname === "/api/datastores") {
771
+ const target = needCloud(server, url, res, "list data stores");
772
+ if (target) {
773
+ send(res, 200, { dataStores: await listDataStores(target.cloud) });
774
+ }
775
+ return;
776
+ }
777
+ if (url.pathname === "/api/entry") {
778
+ await handleEntry(server, method, url, req, res);
779
+ return;
780
+ }
781
+ if (url.pathname === "/api/gifts") {
782
+ await handleGifts(server, method, url, req, res);
783
+ return;
784
+ }
785
+ if (method === "GET" && url.pathname === "/api/page") {
786
+ await handlePage(server, url, res);
787
+ return;
788
+ }
789
+ if (method === "POST" && url.pathname === "/api/event-image") {
790
+ const target = needCloud(server, url, res, "upload event images");
791
+ if (target) {
792
+ const png = await sharp(await readBody(req)).resize(1920, 1080, { fit: "cover" }).png().toBuffer();
793
+ const assetId = await target.cloud.uploadImage(new Blob([new Uint8Array(png)], { type: "image/png" }), `${target.config.name} event`);
794
+ send(res, 200, { assetId });
795
+ }
796
+ return;
797
+ }
798
+ if (url.pathname === "/api/events") {
799
+ await handleEvents(server, method, url, req, res);
800
+ return;
801
+ }
802
+ if (method === "GET" && url.pathname === "/api/items") {
803
+ await handleItems(server, url, res);
804
+ return;
805
+ }
806
+ if (method === "GET" && url.pathname === "/api/file") {
807
+ handleFile(server, url, res);
808
+ return;
809
+ }
810
+ if (method === "GET" && url.pathname === "/api/lookup") {
811
+ send(res, 200, await lookupGame(url.searchParams.get("q") ?? ""));
812
+ return;
813
+ }
814
+ if (method === "GET" && url.pathname === "/api/icon") {
815
+ await handleIcon(server, url, res);
816
+ return;
817
+ }
818
+ if (method === "POST" && url.pathname === "/api/upload") {
819
+ await handleUpload(server, url, req, res);
820
+ return;
821
+ }
822
+ if (method === "GET" && url.pathname === "/api/preflight") {
823
+ await handlePreflight(server, url, res);
824
+ return;
825
+ }
826
+ if (method === "GET" && url.pathname === "/api/icons.zip") {
827
+ await handleIconZip(server, url, res);
828
+ return;
829
+ }
830
+ if (method === "GET" && url.pathname === "/api/kit.zip") {
831
+ res.writeHead(200, { "content-type": ZIP_TYPE, "content-disposition": 'attachment; filename="UpdateDay-roblox-kit.zip"' });
832
+ res.end(kitZip());
833
+ return;
834
+ }
835
+ if (url.pathname === "/api/art") {
836
+ await handleArt(server, method, url, req, res);
837
+ return;
838
+ }
839
+ if (url.pathname === "/api/moderation") {
840
+ await handleModeration(server, method, url, res);
841
+ return;
842
+ }
843
+ if (method === "GET" && url.pathname === "/api/style-art") {
844
+ const name = url.searchParams.get("name") ?? "";
845
+ if (!HOURGLASS_STYLES.includes(name)) {
846
+ send(res, 404, { error: "No such style" });
847
+ return;
848
+ }
849
+ const file = name === "original" ? join(dirname(stylePath("classic")), "../hourglass/default.png") : stylePath(name);
850
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "max-age=3600" });
851
+ res.end(readFileSync(file));
852
+ return;
853
+ }
854
+ if (method === "GET" && url.pathname === "/api/history") {
855
+ const picked = url.searchParams.get("game");
856
+ const config = picked === null ? null : server.draftConfigs[Number(picked)];
857
+ send(res, 200, { entries: readHistory(server.baseDir, config?.universeId, Number(url.searchParams.get("limit")) || undefined) });
858
+ return;
859
+ }
860
+ if (method === "POST" && url.pathname === "/api/notify-test") {
861
+ await handleNotifyTest(server, url, res);
862
+ return;
863
+ }
864
+ if (method === "POST" && url.pathname === "/api/key") {
865
+ const { env, value, kind } = JSON.parse((await readBody(req)).toString("utf8"));
866
+ if (kind === "webhook" && value && !isWebhookUrl(String(value))) {
867
+ send(res, 400, { error: "That isn't a Discord webhook link (https://discord.com/api/webhooks/...)" });
868
+ return;
869
+ }
870
+ if (typeof env === "string" && /^[A-Z0-9_]+$/i.test(env)) {
871
+ if (value) {
872
+ server.sessionKeys[env] = String(value);
873
+ }
874
+ else {
875
+ delete server.sessionKeys[env];
876
+ }
877
+ }
878
+ send(res, 200, { ok: true });
879
+ return;
880
+ }
881
+ if (method === "POST" && url.pathname.startsWith("/api/run/")) {
882
+ await handleRun(server, url.pathname.slice("/api/run/".length), url.search.slice(1), res);
883
+ return;
884
+ }
885
+ if (method === "GET" && url.pathname === "/api/status") {
886
+ send(res, 200, { mode: server.mode, log: server.log.slice(-200) });
887
+ return;
888
+ }
889
+ send(res, 404, { error: "Not found" });
890
+ }
891
+ export function startServer(configPath, port) {
892
+ const fullPath = resolve(configPath);
893
+ const server = {
894
+ configPath: fullPath,
895
+ baseDir: dirname(fullPath),
896
+ port,
897
+ draft: null,
898
+ draftConfigs: [],
899
+ draftError: null,
900
+ revision: 0,
901
+ iconCache: new Map(),
902
+ renderCache: new Map(),
903
+ itemCache: new Map(),
904
+ mode: "idle",
905
+ stopRequested: false,
906
+ log: [],
907
+ sessionKeys: {},
908
+ };
909
+ applyDraft(server, readDraft(fullPath));
910
+ captureConsole(server);
911
+ const http = createServer((req, res) => {
912
+ if (!isLocalRequest(req, port)) {
913
+ send(res, 403, { error: "Local access only" });
914
+ return;
915
+ }
916
+ route(server, req, res).catch((error) => {
917
+ send(res, 500, { error: error instanceof Error ? error.message : String(error) });
918
+ });
919
+ });
920
+ return new Promise((resolveServer) => {
921
+ http.listen(port, HOST, () => resolveServer({
922
+ url: `http://${HOST}:${port}/`,
923
+ close: () => new Promise((done) => {
924
+ http.closeAllConnections();
925
+ http.close(() => done());
926
+ }),
927
+ }));
928
+ });
929
+ }