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/config.js ADDED
@@ -0,0 +1,261 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { HOURGLASS_STYLES } from "./icons.js";
4
+ const DEFAULT_LANGUAGE = "en";
5
+ const DEFAULT_API_KEY_ENV = "UPDATEDAY_API_KEY";
6
+ const DEFAULT_ADMIN_ABUSE_MINUTES = 60;
7
+ const STATE_PREFIX = "updateday";
8
+ const STATE_SUFFIX = ".state.json";
9
+ const MERGED_SECTIONS = ["countdown", "sale", "event", "broadcast", "restartServers", "title"];
10
+ const DEFAULT_TITLE = { format: "[{time}] {name}", nowFormat: "[LIVE] {name}", afterFormat: "{name}" };
11
+ const NAME_TOKEN = "{name}";
12
+ const SNAPSHOT_SUFFIX = ".original-icon.png";
13
+ const NOW_MARK = "now";
14
+ const NEW_ICON_TIMINGS = ["now", "end"];
15
+ const REPEAT_PERIODS_MS = { day: 864e5, week: 7 * 864e5, "2weeks": 14 * 864e5 };
16
+ const REPEAT_GRACE_MS = 30 * 60 * 1000;
17
+ const HOUR_MS = 60 * 60 * 1000;
18
+ const MINUTE_MS = 60 * 1000;
19
+ const MAX_MONTH_STEPS = 1200;
20
+ export const DEFAULT_WEBHOOK_ENV = "UPDATEDAY_DISCORD_WEBHOOK";
21
+ export const DEFAULT_NOTIFY_EVENTS = ["reminders", "failures", "live", "finished"];
22
+ const NOTIFY_EVENTS = ["reminders", "failures", "live", "finished", "steps"];
23
+ export const DEFAULT_MARKS = ["24h", "12h", "6h", "5h", "4h", "3h", "2h", "1h", "45m", "30m", "15m", "10m", "5m"];
24
+ const DEFAULT_COUNTDOWN = {
25
+ marks: DEFAULT_MARKS,
26
+ skip: [],
27
+ newIconAt: "end",
28
+ iconMethod: "reminder",
29
+ uploadLeadSeconds: 90,
30
+ localizeLabels: true,
31
+ style: "original",
32
+ hourglassSize: 0.66,
33
+ hourglassY: 0.4,
34
+ textSize: 0.15,
35
+ textY: 0.85,
36
+ anchor: "center",
37
+ scale: 0.88,
38
+ dim: 0.45,
39
+ label: true,
40
+ labelColor: "#ffffff",
41
+ labelOutline: "#000000",
42
+ };
43
+ function countdownConfig(raw, baseDir) {
44
+ if (raw === false) {
45
+ return undefined;
46
+ }
47
+ const merged = { ...DEFAULT_COUNTDOWN, ...(raw ?? {}) };
48
+ const resolvePath = (path) => (path ? resolve(baseDir, path) : undefined);
49
+ const known = new Set([...merged.marks, NOW_MARK]);
50
+ const unknown = merged.skip.filter((mark) => !known.has(mark));
51
+ if (unknown.length > 0) {
52
+ fail(`countdown.skip has stages that aren't in the countdown: ${unknown.join(", ")}`);
53
+ }
54
+ if (!["languageSwap", "reminder"].includes(merged.iconMethod)) {
55
+ fail(`countdown.iconMethod must be "languageSwap" or "reminder"`);
56
+ }
57
+ if (!HOURGLASS_STYLES.includes(merged.style)) {
58
+ fail(`countdown.style must be one of ${HOURGLASS_STYLES.join(", ")}`);
59
+ }
60
+ if (!NEW_ICON_TIMINGS.includes(merged.newIconAt)) {
61
+ fail(`countdown.newIconAt must be "now" or "end"`);
62
+ }
63
+ return {
64
+ ...merged,
65
+ overlayDir: resolvePath(merged.overlayDir),
66
+ customDir: resolvePath(merged.customDir),
67
+ newIcon: resolvePath(merged.newIcon ?? merged.finalIcon),
68
+ };
69
+ }
70
+ export function activeMarks(countdown) {
71
+ return countdown.marks.filter((mark) => !countdown.skip.includes(mark));
72
+ }
73
+ export function showsNow(countdown) {
74
+ return !countdown.skip.includes(NOW_MARK);
75
+ }
76
+ export function occurrence(base, every, index) {
77
+ if (every === "month") {
78
+ const date = new Date(base);
79
+ date.setUTCMonth(date.getUTCMonth() + index);
80
+ return date;
81
+ }
82
+ return new Date(base.getTime() + index * REPEAT_PERIODS_MS[every]);
83
+ }
84
+ function occurrenceTailMs(raw) {
85
+ let tail = (raw.adminAbuseMinutes ?? DEFAULT_ADMIN_ABUSE_MINUTES) * MINUTE_MS;
86
+ const base = Date.parse(raw.updateAt);
87
+ if (raw.sale) {
88
+ const start = raw.sale.startsAt ? Date.parse(raw.sale.startsAt) : base;
89
+ const end = raw.sale.endsAt ? Date.parse(raw.sale.endsAt) : start + (raw.sale.durationHours ?? 48) * HOUR_MS;
90
+ tail = Math.max(tail, end - base);
91
+ }
92
+ if (raw.event) {
93
+ tail = Math.max(tail, (raw.event.durationHours ?? 2) * HOUR_MS);
94
+ }
95
+ return tail + REPEAT_GRACE_MS;
96
+ }
97
+ function repeatIndexFor(raw, now) {
98
+ const every = raw.repeat.every;
99
+ const base = new Date(raw.updateAt);
100
+ const tail = occurrenceTailMs(raw);
101
+ let index = 0;
102
+ if (every === "month") {
103
+ while (index < MAX_MONTH_STEPS && occurrence(base, every, index).getTime() + tail < now.getTime()) {
104
+ index++;
105
+ }
106
+ }
107
+ else {
108
+ index = Math.max(0, Math.ceil((now.getTime() - tail - base.getTime()) / REPEAT_PERIODS_MS[every]));
109
+ }
110
+ const until = raw.repeat.until ? Date.parse(raw.repeat.until) : NaN;
111
+ while (index > 0 && !Number.isNaN(until) && occurrence(base, every, index).getTime() > until) {
112
+ index--;
113
+ }
114
+ return index;
115
+ }
116
+ function shiftIso(value, deltaMs) {
117
+ return value ? new Date(Date.parse(value) + deltaMs).toISOString() : value;
118
+ }
119
+ function applyRepeat(raw, now) {
120
+ if (!raw.repeat) {
121
+ return { raw, index: 0 };
122
+ }
123
+ if (!["day", "week", "2weeks", "month"].includes(raw.repeat.every)) {
124
+ fail(`repeat.every must be "day", "week", "2weeks" or "month"`);
125
+ }
126
+ const index = repeatIndexFor(raw, now);
127
+ if (index === 0) {
128
+ return { raw, index };
129
+ }
130
+ const updateAt = occurrence(new Date(raw.updateAt), raw.repeat.every, index);
131
+ const delta = updateAt.getTime() - Date.parse(raw.updateAt);
132
+ const sale = raw.sale ? { ...raw.sale, startsAt: shiftIso(raw.sale.startsAt, delta), endsAt: shiftIso(raw.sale.endsAt, delta) } : raw.sale;
133
+ return { raw: { ...raw, updateAt: updateAt.toISOString(), sale }, index };
134
+ }
135
+ function titleConfig(raw, label) {
136
+ if (!raw) {
137
+ return undefined;
138
+ }
139
+ const merged = { ...DEFAULT_TITLE, ...raw };
140
+ for (const key of ["format", "nowFormat"]) {
141
+ if (!merged[key].includes(NAME_TOKEN)) {
142
+ fail(`${label}: title.${key} must include {name}`);
143
+ }
144
+ }
145
+ return merged;
146
+ }
147
+ function notifyConfig(raw) {
148
+ if (!raw) {
149
+ return undefined;
150
+ }
151
+ const events = (raw.events ?? DEFAULT_NOTIFY_EVENTS).filter((event) => NOTIFY_EVENTS.includes(event));
152
+ return { discordWebhookEnv: raw.discordWebhookEnv || DEFAULT_WEBHOOK_ENV, events, mention: raw.mention || undefined };
153
+ }
154
+ function fail(message) {
155
+ throw new Error(`Config: ${message}`);
156
+ }
157
+ function mergeSection(shared, own) {
158
+ if (own === false || own === null) {
159
+ return false;
160
+ }
161
+ if (own === undefined) {
162
+ return shared;
163
+ }
164
+ if (shared && typeof shared === "object" && typeof own === "object" && !Array.isArray(own)) {
165
+ return { ...shared, ...own };
166
+ }
167
+ return own;
168
+ }
169
+ function mergeGame(shared, game) {
170
+ const merged = { ...shared, ...game };
171
+ for (const section of MERGED_SECTIONS) {
172
+ merged[section] = mergeSection(shared[section], game[section]);
173
+ }
174
+ return merged;
175
+ }
176
+ function enabledSections(raw) {
177
+ const sections = {};
178
+ for (const section of MERGED_SECTIONS) {
179
+ sections[section] = raw[section] === false ? undefined : raw[section];
180
+ }
181
+ return sections;
182
+ }
183
+ function statePathFor(raw, baseDir, multi) {
184
+ if (raw.statePath) {
185
+ return resolve(baseDir, raw.statePath);
186
+ }
187
+ const fileName = multi ? `${STATE_PREFIX}.${raw.universeId}${STATE_SUFFIX}` : STATE_PREFIX + STATE_SUFFIX;
188
+ return resolve(baseDir, fileName);
189
+ }
190
+ function buildConfig(original, baseDir, multi, now) {
191
+ const label = original.name ?? original.universeId ?? "game";
192
+ if (Number.isNaN(Date.parse(original.updateAt ?? ""))) {
193
+ fail(`${label}: updateAt must be an ISO date with a timezone, e.g. 2026-11-01T17:00:00Z`);
194
+ }
195
+ const { raw, index } = applyRepeat(original, now);
196
+ return buildOccurrence(raw, baseDir, multi, index, original.updateAt);
197
+ }
198
+ function buildOccurrence(raw, baseDir, multi, repeatIndex, baseUpdateAt) {
199
+ const label = raw.name ?? raw.universeId ?? "game";
200
+ if (!Number.isInteger(raw.universeId)) {
201
+ fail(`${label}: universeId must be a number`);
202
+ }
203
+ if (Number.isNaN(Date.parse(raw.updateAt ?? ""))) {
204
+ fail(`${label}: updateAt must be an ISO date with a timezone, e.g. 2026-11-01T17:00:00Z`);
205
+ }
206
+ if (raw.sale && !(raw.sale.percentOff > 0 && raw.sale.percentOff < 100)) {
207
+ fail(`${label}: sale.percentOff must be between 1 and 99`);
208
+ }
209
+ if (raw.sale?.startsAt && raw.sale?.endsAt && Date.parse(raw.sale.endsAt) <= Date.parse(raw.sale.startsAt)) {
210
+ fail(`${label}: the sale has to end after it starts`);
211
+ }
212
+ const statePath = statePathFor(raw, baseDir, multi);
213
+ return {
214
+ ...raw,
215
+ ...enabledSections(raw),
216
+ name: String(raw.name ?? raw.universeId),
217
+ apiKeyEnv: raw.apiKeyEnv ?? DEFAULT_API_KEY_ENV,
218
+ languageCode: raw.languageCode ?? DEFAULT_LANGUAGE,
219
+ adminAbuseMinutes: raw.adminAbuseMinutes ?? DEFAULT_ADMIN_ABUSE_MINUTES,
220
+ countdown: countdownConfig(raw.countdown, baseDir),
221
+ notify: notifyConfig(raw.notify),
222
+ title: titleConfig(raw.title, label),
223
+ repeatIndex,
224
+ baseUpdateAt,
225
+ statePath,
226
+ snapshotPath: statePath.replace(/\.json$/, "") + SNAPSHOT_SUFFIX,
227
+ baseDir,
228
+ };
229
+ }
230
+ export function parseConfigs(raw, baseDir, now = new Date()) {
231
+ if (!Array.isArray(raw.games)) {
232
+ return [buildConfig(raw, baseDir, false, now)];
233
+ }
234
+ const { games, ...shared } = raw;
235
+ const configs = games.map((game) => buildConfig(mergeGame(shared, game), baseDir, true, now));
236
+ const ids = configs.map((config) => config.universeId);
237
+ if (new Set(ids).size !== ids.length) {
238
+ fail("the same universeId is listed twice in games");
239
+ }
240
+ return configs;
241
+ }
242
+ export function flattenSingleGame(raw) {
243
+ if (!Array.isArray(raw.games) || raw.games.length !== 1) {
244
+ return raw;
245
+ }
246
+ const { games, ...shared } = raw;
247
+ return mergeGame(shared, games[0]);
248
+ }
249
+ export function nextOccurrence(config) {
250
+ if (!config.repeat) {
251
+ return null;
252
+ }
253
+ const next = occurrence(new Date(config.baseUpdateAt), config.repeat.every, config.repeatIndex + 1);
254
+ const until = config.repeat.until ? Date.parse(config.repeat.until) : NaN;
255
+ return !Number.isNaN(until) && next.getTime() > until ? null : next;
256
+ }
257
+ export function loadConfigs(path, now = new Date()) {
258
+ const fullPath = resolve(path);
259
+ const raw = JSON.parse(readFileSync(fullPath, "utf8"));
260
+ return parseConfigs(raw, dirname(fullPath), now);
261
+ }
package/dist/doctor.js ADDED
@@ -0,0 +1,119 @@
1
+ import { OpenCloudError } from "./openCloud.js";
2
+ const PROBE_KEY = "UpdateDayKeyCheck";
3
+ const PROBE_TOPIC = "UpdateDayKeyCheck";
4
+ function entryPath(cloud, store, key) {
5
+ return `/cloud/v2/universes/${cloud.universeId}/data-stores/${encodeURIComponent(store)}/entries/${encodeURIComponent(key)}`;
6
+ }
7
+ async function readMissingEntry(cloud) {
8
+ try {
9
+ await cloud.request("GET", entryPath(cloud, PROBE_KEY, PROBE_KEY), "read entry");
10
+ }
11
+ catch (error) {
12
+ if (error instanceof OpenCloudError && error.status === 404) {
13
+ return;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ const PROBES = [
19
+ {
20
+ feature: "Game info",
21
+ permission: "Universe: read",
22
+ used: () => true,
23
+ run: (cloud) => cloud.getUniverse(),
24
+ },
25
+ {
26
+ feature: "Name and description",
27
+ permission: "Places: read + write",
28
+ used: (config) => Boolean(config.nameChanges?.length || config.title),
29
+ run: async (cloud) => cloud.getPlace(await cloud.rootPlaceId()),
30
+ note: "Write is checked the first time a name change runs.",
31
+ },
32
+ {
33
+ feature: "Countdown icons",
34
+ permission: "Legacy universe: manage",
35
+ used: (config) => Boolean(config.countdown),
36
+ run: (cloud) => cloud.listIcons(),
37
+ },
38
+ {
39
+ feature: "Sale: game passes",
40
+ permission: "Game passes: read + write",
41
+ used: (config) => Boolean(config.sale),
42
+ run: (cloud) => cloud.listItems("gamePass"),
43
+ note: "Write is checked when the sale starts; every price is read back.",
44
+ },
45
+ {
46
+ feature: "Sale: developer products",
47
+ permission: "Developer products: read + write",
48
+ used: (config) => Boolean(config.sale),
49
+ run: (cloud) => cloud.listItems("developerProduct"),
50
+ },
51
+ {
52
+ feature: "Events",
53
+ permission: "Universe events: read + write",
54
+ used: (config) => Boolean(config.event),
55
+ run: (cloud) => cloud.request("GET", `/virtual-events/v3/universes/${cloud.universeId}/game-events`, "list events"),
56
+ },
57
+ {
58
+ feature: "In-game warnings",
59
+ permission: "Messaging service: publish",
60
+ used: (config) => Boolean(config.broadcast),
61
+ run: (cloud) => cloud.publishMessage(PROBE_TOPIC, "key check"),
62
+ },
63
+ {
64
+ feature: "Player data: list stores",
65
+ permission: "DataStore: List data stores",
66
+ used: (config) => Boolean(config.playerData),
67
+ run: (cloud) => cloud.request("GET", `/cloud/v2/universes/${cloud.universeId}/data-stores?maxPageSize=1`, "list data stores"),
68
+ },
69
+ {
70
+ feature: "Player data and gifts",
71
+ permission: "DataStore: Read entry + Create entry + Update entry",
72
+ used: (config) => Boolean(config.playerData || config.gifts),
73
+ run: (cloud) => readMissingEntry(cloud),
74
+ note: "Create and update are checked on the first save.",
75
+ },
76
+ {
77
+ feature: "Server restarts",
78
+ permission: "Universe: write",
79
+ used: (config) => Boolean(config.restartServers),
80
+ note: "Can't be checked without restarting servers; checked when it runs.",
81
+ },
82
+ {
83
+ feature: "Publishing the place",
84
+ permission: "Places: publish (universe-places:write)",
85
+ used: (config) => Boolean(config.publish),
86
+ note: "Checked when it runs.",
87
+ },
88
+ ];
89
+ function describe(error) {
90
+ if (error instanceof OpenCloudError) {
91
+ if (error.status === 401 || error.status === 403) {
92
+ const ip = /ip/i.test(error.body);
93
+ return {
94
+ status: "missing",
95
+ detail: ip ? "Roblox refused this computer's IP address: add it to the key's accepted IPs." : "The key doesn't have this permission for this game.",
96
+ };
97
+ }
98
+ return { status: "error", detail: `Roblox answered ${error.status}: ${error.body.slice(0, 160)}` };
99
+ }
100
+ return { status: "error", detail: error instanceof Error ? error.message : String(error) };
101
+ }
102
+ export async function checkKey(cloud, config) {
103
+ const results = [];
104
+ for (const spec of PROBES) {
105
+ const base = { feature: spec.feature, permission: spec.permission, used: spec.used(config) };
106
+ if (!spec.run) {
107
+ results.push({ ...base, status: "untested", detail: spec.note });
108
+ continue;
109
+ }
110
+ try {
111
+ await spec.run(cloud, config);
112
+ results.push({ ...base, status: "ok", detail: spec.note });
113
+ }
114
+ catch (error) {
115
+ results.push({ ...base, ...describe(error) });
116
+ }
117
+ }
118
+ return results;
119
+ }