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/runner.js ADDED
@@ -0,0 +1,360 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { renderIcon, snapshotIcon } from "./icons.js";
4
+ import { finishSwap, startSwap, uploadAll } from "./swap.js";
5
+ import { OpenCloud } from "./openCloud.js";
6
+ import { logLine, saveState } from "./state.js";
7
+ const BROADCAST_MAX_LATE_MS = 2 * 60 * 1000;
8
+ const EVENT_LEAD_MS = 20 * 60 * 1000;
9
+ const PRICE_WRITE_GAP_MS = 400;
10
+ const MAX_NAME_LENGTH = 50;
11
+ const MAX_TITLE_LENGTH = 50;
12
+ const NAME_TOKEN = "{name}";
13
+ const TIME_TOKEN = /\{time\}/g;
14
+ const PERCENT_TOKEN = /\{percent\}/g;
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+ function readBlob(config, file) {
19
+ return new Blob([readFileSync(resolve(config.baseDir, file))]);
20
+ }
21
+ function salePrice(original, percentOff) {
22
+ return Math.max(1, Math.round(original * (1 - percentOff / 100)));
23
+ }
24
+ function requireCloud(ctx) {
25
+ if (!ctx.cloud) {
26
+ throw new Error("No API key loaded");
27
+ }
28
+ return ctx.cloud;
29
+ }
30
+ async function resolveTargets(cloud, ctx) {
31
+ const sale = ctx.config.sale;
32
+ const passes = sale.gamePasses === "all"
33
+ ? await cloud.listPriceTargets("gamePass")
34
+ : sale.gamePasses.map((id) => ({ kind: "gamePass", id }));
35
+ const products = sale.developerProducts === "all"
36
+ ? await cloud.listPriceTargets("developerProduct")
37
+ : sale.developerProducts.map((id) => ({ kind: "developerProduct", id }));
38
+ return [...passes, ...products];
39
+ }
40
+ async function writeVerifiedPrice(cloud, target, price) {
41
+ await cloud.setPrice(target, price);
42
+ const after = await cloud.getPrice(target);
43
+ if (after.price !== price) {
44
+ throw new Error(`${target.kind} ${target.id}: wrote ${price} but Roblox reports ${after.price}. Check the key has the write scope.`);
45
+ }
46
+ }
47
+ export function taggedName(name, tag, position, percentOff) {
48
+ const text = tag.replace(PERCENT_TOKEN, String(percentOff)).trim();
49
+ if (!text || name.includes(text)) {
50
+ return name;
51
+ }
52
+ const room = Math.max(0, MAX_NAME_LENGTH - text.length - 1);
53
+ const base = name.length > room ? name.slice(0, room).trimEnd() : name;
54
+ return position === "suffix" ? `${base} ${text}` : `${text} ${base}`;
55
+ }
56
+ async function writeVerifiedName(cloud, target, name) {
57
+ await cloud.setName(target, name);
58
+ const after = await cloud.getPrice(target);
59
+ if (after.name !== name) {
60
+ throw new Error(`wrote name "${name}" but Roblox reports "${after.name}"`);
61
+ }
62
+ }
63
+ async function tagName(cloud, ctx, saved) {
64
+ const sale = ctx.config.sale;
65
+ if (!sale.nameTag) {
66
+ return;
67
+ }
68
+ const tagged = taggedName(saved.name, sale.nameTag, sale.nameTagPosition ?? "prefix", sale.percentOff);
69
+ if (tagged === saved.name) {
70
+ return;
71
+ }
72
+ saved.taggedName = tagged;
73
+ saveState(ctx.config.statePath, ctx.state);
74
+ try {
75
+ await writeVerifiedName(cloud, saved, tagged);
76
+ logLine(ctx.state, ` renamed to "${tagged}"`);
77
+ }
78
+ catch (error) {
79
+ logLine(ctx.state, ` rename failed, price still discounted: ${error instanceof Error ? error.message : error}`);
80
+ }
81
+ }
82
+ async function resumeItem(cloud, ctx, saved) {
83
+ const live = await cloud.getPrice(saved);
84
+ if (live.price !== saved.salePrice) {
85
+ await writeVerifiedPrice(cloud, saved, saved.salePrice);
86
+ logLine(ctx.state, ` ${saved.name || saved.id}: ${saved.originalPrice} -> ${saved.salePrice} (finished after a restart)`);
87
+ }
88
+ if (ctx.config.sale.nameTag && (!saved.taggedName || live.name !== saved.taggedName)) {
89
+ await tagName(cloud, ctx, saved);
90
+ }
91
+ }
92
+ async function startSale(ctx) {
93
+ const cloud = requireCloud(ctx);
94
+ const sale = ctx.config.sale;
95
+ for (const target of await resolveTargets(cloud, ctx)) {
96
+ const existing = ctx.state.prices.find((saved) => saved.kind === target.kind && saved.id === target.id);
97
+ if (existing) {
98
+ if (!existing.restored) {
99
+ await resumeItem(cloud, ctx, existing);
100
+ }
101
+ continue;
102
+ }
103
+ const info = await cloud.getPrice(target);
104
+ if (info.price === null || !info.isForSale) {
105
+ logLine(ctx.state, ` skip ${target.kind} ${target.id} (not for sale)`);
106
+ continue;
107
+ }
108
+ const discounted = salePrice(info.price, sale.percentOff);
109
+ const saved = {
110
+ kind: target.kind,
111
+ id: target.id,
112
+ name: info.name,
113
+ originalPrice: info.price,
114
+ salePrice: discounted,
115
+ restored: false,
116
+ };
117
+ ctx.state.prices.push(saved);
118
+ saveState(ctx.config.statePath, ctx.state);
119
+ await writeVerifiedPrice(cloud, target, discounted);
120
+ logLine(ctx.state, ` ${info.name || target.id}: ${info.price} -> ${discounted}`);
121
+ await tagName(cloud, ctx, saved);
122
+ await sleep(PRICE_WRITE_GAP_MS);
123
+ }
124
+ }
125
+ export async function restorePrices(ctx) {
126
+ const cloud = requireCloud(ctx);
127
+ for (const saved of ctx.state.prices) {
128
+ const nameOwed = Boolean(saved.taggedName) && !saved.nameRestored;
129
+ if (saved.restored && !nameOwed) {
130
+ continue;
131
+ }
132
+ if (!saved.restored) {
133
+ await writeVerifiedPrice(cloud, saved, saved.originalPrice);
134
+ saved.restored = true;
135
+ saveState(ctx.config.statePath, ctx.state);
136
+ logLine(ctx.state, ` ${saved.name || saved.id}: restored ${saved.originalPrice}`);
137
+ }
138
+ if (nameOwed) {
139
+ await writeVerifiedName(cloud, saved, saved.name);
140
+ saved.nameRestored = true;
141
+ saveState(ctx.config.statePath, ctx.state);
142
+ logLine(ctx.state, ` name restored to "${saved.name}"`);
143
+ }
144
+ await sleep(PRICE_WRITE_GAP_MS);
145
+ }
146
+ }
147
+ const REMINDER_DIR = "updateday-icons";
148
+ function writeReminder(ctx, mark, image) {
149
+ const folder = resolve(ctx.config.baseDir, REMINDER_DIR, String(ctx.config.universeId));
150
+ mkdirSync(folder, { recursive: true });
151
+ const file = resolve(folder, `${mark}.png`);
152
+ writeFileSync(file, image);
153
+ const what = mark === "final" ? (ctx.config.countdown?.newIcon ? "the new game icon" : "the original game icon") : `the ${mark.toUpperCase()} countdown icon`;
154
+ const line = `REMINDER ${ctx.config.name}: upload ${what} in Creator Hub now: ${file}`;
155
+ ctx.state.reminders = [...(ctx.state.reminders ?? []), line];
156
+ logLine(ctx.state, line);
157
+ const title = mark === "final" ? "Countdown over: upload the game icon" : `Upload the ${mark === "now" ? "NOW" : mark} countdown icon`;
158
+ ctx.notify?.({ event: "reminders", title, text: `Upload ${what} in Creator Hub now. It's attached, and also saved at ${file}`, image });
159
+ }
160
+ async function uploadIcon(ctx, mark) {
161
+ if (!existsSync(ctx.config.snapshotPath)) {
162
+ throw new Error(`Original icon snapshot missing at ${ctx.config.snapshotPath}`);
163
+ }
164
+ const countdown = ctx.config.countdown;
165
+ const base = readFileSync(ctx.config.snapshotPath);
166
+ const image = await renderIcon(base, mark, countdown);
167
+ if (countdown.iconMethod === "reminder") {
168
+ writeReminder(ctx, mark, image);
169
+ return;
170
+ }
171
+ if (mark === "final") {
172
+ await finishSwap(requireCloud(ctx), ctx.state, ctx.config.statePath);
173
+ if (countdown.newIcon) {
174
+ writeReminder(ctx, mark, image);
175
+ }
176
+ return;
177
+ }
178
+ const cloud = requireCloud(ctx);
179
+ await startSwap(cloud, ctx.state, ctx.config.statePath, countdown.swapLanguage);
180
+ const pngBlob = (png) => new Blob([new Uint8Array(png)], { type: "image/png" });
181
+ await uploadAll(cloud, ctx.state, async (languageCode) => pngBlob(await renderIcon(base, mark, countdown, languageCode)));
182
+ }
183
+ export function titleTime(mark) {
184
+ return mark === "now" ? "LIVE" : mark.toUpperCase();
185
+ }
186
+ export function titleFor(format, name, time) {
187
+ const shell = format.replace(TIME_TOKEN, time);
188
+ if (!shell.includes(NAME_TOKEN)) {
189
+ return shell.trim().slice(0, MAX_TITLE_LENGTH);
190
+ }
191
+ const room = Math.max(0, MAX_TITLE_LENGTH - (shell.length - NAME_TOKEN.length));
192
+ const fitted = name.length > room ? name.slice(0, room).trimEnd() : name;
193
+ return shell.replace(NAME_TOKEN, fitted).trim();
194
+ }
195
+ async function writeTitle(cloud, title) {
196
+ const result = await cloud.setNameDescription(title);
197
+ if (result.name !== title) {
198
+ throw new Error(`wrote the title "${title}" but Roblox reports "${result.name}"`);
199
+ }
200
+ }
201
+ async function applyTitle(ctx, mark) {
202
+ const cloud = requireCloud(ctx);
203
+ const settings = ctx.config.title;
204
+ if (mark === "save") {
205
+ if (!ctx.state.title) {
206
+ const place = await cloud.getPlace(await cloud.rootPlaceId());
207
+ ctx.state.title = { original: String(place?.displayName ?? ""), restored: false };
208
+ saveState(ctx.config.statePath, ctx.state);
209
+ logLine(ctx.state, ` saved the title "${ctx.state.title.original}"`);
210
+ }
211
+ return;
212
+ }
213
+ const saved = ctx.state.title;
214
+ if (!saved?.original) {
215
+ throw new Error("The game's title wasn't saved yet");
216
+ }
217
+ const format = mark === "final" ? settings.afterFormat : mark === "now" ? settings.nowFormat : settings.format;
218
+ const next = titleFor(format, saved.original, titleTime(mark));
219
+ await writeTitle(cloud, next);
220
+ if (mark === "final") {
221
+ saved.restored = true;
222
+ saveState(ctx.config.statePath, ctx.state);
223
+ }
224
+ logLine(ctx.state, ` title is now "${next}"`);
225
+ }
226
+ export async function restoreTitle(ctx) {
227
+ const saved = ctx.state.title;
228
+ if (!saved || saved.restored || !saved.original) {
229
+ return;
230
+ }
231
+ await writeTitle(requireCloud(ctx), saved.original);
232
+ saved.restored = true;
233
+ saveState(ctx.config.statePath, ctx.state);
234
+ logLine(ctx.state, ` title restored to "${saved.original}"`);
235
+ }
236
+ export async function endCountdown(ctx) {
237
+ await finishSwap(requireCloud(ctx), ctx.state, ctx.config.statePath);
238
+ await restoreTitle(ctx);
239
+ }
240
+ async function createEvent(ctx) {
241
+ const event = ctx.config.event;
242
+ const updateAt = new Date(ctx.config.updateAt);
243
+ const earliest = new Date(ctx.now.getTime() + EVENT_LEAD_MS);
244
+ const start = updateAt > earliest ? updateAt : earliest;
245
+ const end = new Date(start.getTime() + event.durationHours * 60 * 60 * 1000);
246
+ const created = await requireCloud(ctx).createEvent({
247
+ title: event.title,
248
+ subtitle: event.subtitle,
249
+ description: event.description,
250
+ startTime: start.toISOString(),
251
+ endTime: end.toISOString(),
252
+ visibility: event.visibility,
253
+ });
254
+ ctx.state.eventId = String(created?.id ?? created?.eventId ?? "");
255
+ }
256
+ async function perform(step, ctx) {
257
+ const action = step.action;
258
+ const config = ctx.config;
259
+ switch (action.type) {
260
+ case "snapshotIcon":
261
+ await snapshotIcon(config.universeId, config.snapshotPath);
262
+ return;
263
+ case "icon":
264
+ await uploadIcon(ctx, action.mark);
265
+ return;
266
+ case "title":
267
+ await applyTitle(ctx, action.mark);
268
+ return;
269
+ case "nameDescription":
270
+ await requireCloud(ctx).setNameDescription(action.name, action.description);
271
+ return;
272
+ case "saleStart":
273
+ await startSale(ctx);
274
+ return;
275
+ case "saleEnd":
276
+ await restorePrices(ctx);
277
+ return;
278
+ case "event":
279
+ await createEvent(ctx);
280
+ return;
281
+ case "broadcast":
282
+ await requireCloud(ctx).publishMessage(config.broadcast.topic, action.message);
283
+ return;
284
+ case "publish":
285
+ await requireCloud(ctx).publishPlace(config.publish.placeId, readBlob(config, config.publish.file));
286
+ return;
287
+ case "restartServers":
288
+ await requireCloud(ctx).restartServers(config.restartServers.bleedOffMinutes);
289
+ return;
290
+ }
291
+ }
292
+ function skipReason(step, due, ctx) {
293
+ const lateMs = ctx.now.getTime() - step.at.getTime();
294
+ if (step.action.type === "broadcast" && lateMs > BROADCAST_MAX_LATE_MS) {
295
+ return "too late to warn players";
296
+ }
297
+ if (step.action.type === "icon") {
298
+ const laterIcon = due.some((other) => other.action.type === "icon" && other.at > step.at);
299
+ if (laterIcon) {
300
+ return "superseded by a later icon";
301
+ }
302
+ }
303
+ if (step.action.type === "title" && step.action.mark !== "save" && step.action.mark !== "final") {
304
+ const laterTitle = due.some((other) => other.action.type === "title" && other.action.mark !== "save" && other.at > step.at);
305
+ if (laterTitle) {
306
+ return "superseded by a later title";
307
+ }
308
+ }
309
+ return null;
310
+ }
311
+ export async function tick(steps, ctx, dryRun) {
312
+ const due = steps.filter((step) => step.at <= ctx.now && !ctx.state.done[step.id]);
313
+ for (const step of due) {
314
+ const reason = skipReason(step, due, ctx);
315
+ if (reason) {
316
+ logLine(ctx.state, `SKIP ${step.label}: ${reason}`);
317
+ ctx.state.done[step.id] = `skipped: ${reason}`;
318
+ continue;
319
+ }
320
+ if (dryRun) {
321
+ console.log(`WOULD RUN ${step.label}`);
322
+ continue;
323
+ }
324
+ logLine(ctx.state, `RUN ${step.label}`);
325
+ try {
326
+ await perform(step, ctx);
327
+ }
328
+ catch (error) {
329
+ const message = error instanceof Error ? error.message : String(error);
330
+ logLine(ctx.state, `FAILED ${step.label}: ${message}`);
331
+ saveState(ctx.config.statePath, ctx.state);
332
+ await ctx.notify?.({ event: "failures", title: `Failed: ${step.label}`, text: `${message}
333
+
334
+ UpdateDay tries again on the next check.` });
335
+ throw error;
336
+ }
337
+ ctx.state.done[step.id] = new Date().toISOString();
338
+ saveState(ctx.config.statePath, ctx.state);
339
+ await ctx.notify?.({ event: "steps", title: step.label, text: "Done." });
340
+ }
341
+ const remaining = steps.filter((step) => !ctx.state.done[step.id]).length;
342
+ if (!dryRun) {
343
+ await announceMilestones(ctx, remaining);
344
+ saveState(ctx.config.statePath, ctx.state);
345
+ }
346
+ return remaining;
347
+ }
348
+ async function announceMilestones(ctx, remaining) {
349
+ const notified = ctx.state.notified ?? {};
350
+ if (!notified.live && ctx.now >= new Date(ctx.config.updateAt)) {
351
+ notified.live = ctx.now.toISOString();
352
+ ctx.state.notified = notified;
353
+ await ctx.notify?.({ event: "live", title: "The update is live", text: `Update time reached (${new Date(ctx.config.updateAt).toUTCString()}).` });
354
+ }
355
+ if (!notified.finished && remaining === 0) {
356
+ notified.finished = ctx.now.toISOString();
357
+ ctx.state.notified = notified;
358
+ await ctx.notify?.({ event: "finished", title: "Update day finished", text: "Every step ran and everything that needed putting back was put back." });
359
+ }
360
+ }
@@ -0,0 +1,162 @@
1
+ import { activeMarks, DEFAULT_MARKS, showsNow } from "./config.js";
2
+ const UNIT_MS = {
3
+ s: 1000,
4
+ m: 60 * 1000,
5
+ h: 60 * 60 * 1000,
6
+ d: 24 * 60 * 60 * 1000,
7
+ };
8
+ export function parseOffset(text) {
9
+ if (text === "0" || text === "now") {
10
+ return 0;
11
+ }
12
+ const match = /^([+-]?)(\d+(?:\.\d+)?)([smhd])$/.exec(text.trim());
13
+ if (!match) {
14
+ throw new Error(`Bad time offset "${text}" (use e.g. 24h, 30m, -1h, +2h)`);
15
+ }
16
+ const sign = match[1] === "+" ? 1 : -1;
17
+ return sign * Number(match[2]) * UNIT_MS[match[3]];
18
+ }
19
+ function relative(updateAt, offsetMs) {
20
+ return new Date(updateAt.getTime() + offsetMs);
21
+ }
22
+ function countdownSteps(config, updateAt) {
23
+ const countdown = config.countdown;
24
+ if (!countdown) {
25
+ return [];
26
+ }
27
+ const steps = [{
28
+ id: "icon:snapshot",
29
+ at: new Date(0),
30
+ label: "Save the current game icon",
31
+ action: { type: "snapshotIcon" },
32
+ }];
33
+ const lead = countdown.iconMethod === "languageSwap" ? countdown.uploadLeadSeconds * UNIT_MS.s : 0;
34
+ const verb = countdown.iconMethod === "reminder" ? "Reminder: upload countdown icon" : "Countdown icon";
35
+ for (const mark of activeMarks(countdown)) {
36
+ steps.push({
37
+ id: `icon:${mark}`,
38
+ at: relative(updateAt, parseOffset(mark) - lead),
39
+ label: `${verb} ${mark}`,
40
+ action: { type: "icon", mark },
41
+ });
42
+ }
43
+ if (showsNow(countdown)) {
44
+ steps.push({
45
+ id: "icon:now",
46
+ at: relative(updateAt, -lead),
47
+ label: countdown.iconMethod === "reminder" ? `Reminder: upload ${nowLabel(countdown)}` : nowLabel(countdown),
48
+ action: { type: "icon", mark: "now" },
49
+ });
50
+ }
51
+ const finalAt = showsNow(countdown) ? relative(updateAt, config.adminAbuseMinutes * UNIT_MS.m) : updateAt;
52
+ steps.push({
53
+ id: "icon:final",
54
+ at: finalAt,
55
+ label: finalLabel(countdown),
56
+ action: { type: "icon", mark: "final" },
57
+ });
58
+ return steps;
59
+ }
60
+ function finalLabel(countdown) {
61
+ if (countdown.iconMethod === "languageSwap") {
62
+ return countdown.newIcon ? "End countdown (then upload the new icon in Creator Hub)" : "End countdown: original icon back";
63
+ }
64
+ return countdown.newIcon ? "Reminder: upload the new game icon" : "Reminder: put the original icon back";
65
+ }
66
+ function nowLabel(countdown) {
67
+ if (countdown.newIcon && countdown.newIconAt === "now") {
68
+ return "Countdown icon NOW, over the new game icon";
69
+ }
70
+ return "Countdown icon NOW";
71
+ }
72
+ function saleSteps(config, updateAt) {
73
+ const sale = config.sale;
74
+ if (!sale) {
75
+ return [];
76
+ }
77
+ const startsAt = sale.startsAt ? new Date(sale.startsAt) : updateAt;
78
+ const endsAt = sale.endsAt ? new Date(sale.endsAt) : relative(startsAt, sale.durationHours * UNIT_MS.h);
79
+ return [
80
+ {
81
+ id: "sale:start",
82
+ at: startsAt,
83
+ label: `Sale starts (${sale.percentOff}% off)`,
84
+ action: { type: "saleStart" },
85
+ },
86
+ {
87
+ id: "sale:end",
88
+ at: endsAt,
89
+ label: "Sale ends, prices restored",
90
+ action: { type: "saleEnd" },
91
+ },
92
+ ];
93
+ }
94
+ function nameSteps(config, updateAt) {
95
+ return (config.nameChanges ?? []).map((change, index) => ({
96
+ id: `name:${index}`,
97
+ at: relative(updateAt, parseOffset(change.at)),
98
+ label: `Game page text${change.name ? `: "${change.name}"` : ""}`,
99
+ action: { type: "nameDescription", name: change.name, description: change.description },
100
+ }));
101
+ }
102
+ function broadcastSteps(config, updateAt) {
103
+ const broadcast = config.broadcast;
104
+ if (!broadcast) {
105
+ return [];
106
+ }
107
+ return broadcast.marks.map((mark) => ({
108
+ id: `broadcast:${mark}`,
109
+ at: relative(updateAt, parseOffset(mark)),
110
+ label: `In-game warning (${mark})`,
111
+ action: { type: "broadcast", message: JSON.stringify({ updateAt: updateAt.toISOString(), mark }) },
112
+ }));
113
+ }
114
+ function launchSteps(config, updateAt) {
115
+ const steps = [];
116
+ if (config.event) {
117
+ steps.push({ id: "event", at: new Date(0), label: "Create Roblox event", action: { type: "event" } });
118
+ }
119
+ if (config.publish) {
120
+ steps.push({ id: "publish", at: updateAt, label: "Publish place", action: { type: "publish" } });
121
+ }
122
+ if (config.restartServers) {
123
+ steps.push({ id: "restart", at: updateAt, label: "Restart old servers", action: { type: "restartServers" } });
124
+ }
125
+ return steps;
126
+ }
127
+ function titleMarks(config) {
128
+ if (config.title?.marks) {
129
+ return config.title.marks;
130
+ }
131
+ return config.countdown ? activeMarks(config.countdown) : DEFAULT_MARKS;
132
+ }
133
+ function titleSteps(config, updateAt) {
134
+ const title = config.title;
135
+ if (!title) {
136
+ return [];
137
+ }
138
+ const withNow = config.countdown ? showsNow(config.countdown) : true;
139
+ const steps = [{ id: "title:save", at: new Date(0), label: "Save the game's title", action: { type: "title", mark: "save" } }];
140
+ for (const mark of titleMarks(config)) {
141
+ steps.push({ id: `title:${mark}`, at: relative(updateAt, parseOffset(mark)), label: `Title countdown ${mark.toUpperCase()}`, action: { type: "title", mark } });
142
+ }
143
+ if (withNow) {
144
+ steps.push({ id: "title:now", at: updateAt, label: "Title: LIVE", action: { type: "title", mark: "now" } });
145
+ }
146
+ const finalAt = withNow ? relative(updateAt, config.adminAbuseMinutes * UNIT_MS.m) : updateAt;
147
+ const custom = title.afterFormat.trim() !== "{name}";
148
+ steps.push({ id: "title:final", at: finalAt, label: custom ? "Title after the update" : "Title back to normal", action: { type: "title", mark: "final" } });
149
+ return steps;
150
+ }
151
+ export function buildSchedule(config) {
152
+ const updateAt = new Date(config.updateAt);
153
+ const steps = [
154
+ ...launchSteps(config, updateAt),
155
+ ...countdownSteps(config, updateAt),
156
+ ...titleSteps(config, updateAt),
157
+ ...nameSteps(config, updateAt),
158
+ ...broadcastSteps(config, updateAt),
159
+ ...saleSteps(config, updateAt),
160
+ ];
161
+ return steps.sort((a, b) => a.at.getTime() - b.at.getTime());
162
+ }