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
@@ -0,0 +1,349 @@
1
+ import { OpenCloud } from "./openCloud.js";
2
+ const PUBLIC_I18N = "https://gameinternationalization.roblox.com";
3
+ async function publicJson(path) {
4
+ const response = await fetch(PUBLIC_I18N + path);
5
+ return response.ok ? response.json() : { status: response.status, body: await response.text() };
6
+ }
7
+ async function snapshot(universeId) {
8
+ return {
9
+ source: await publicJson(`/v1/source-language/games/${universeId}`),
10
+ supported: await publicJson(`/v2/supported-languages/games/${universeId}`),
11
+ nameDescription: await publicJson(`/v1/name-description/games/${universeId}`),
12
+ };
13
+ }
14
+ async function publicIcon(universeId) {
15
+ const response = await fetch(`https://thumbnails.roblox.com/v1/games/icons?universeIds=${universeId}&size=512x512&format=Png`);
16
+ return response.ok ? response.json() : response.status;
17
+ }
18
+ function canonical(value) {
19
+ const data = value?.data;
20
+ if (!Array.isArray(data)) {
21
+ return JSON.stringify(value);
22
+ }
23
+ return JSON.stringify(data.map((entry) => JSON.stringify(entry)).sort());
24
+ }
25
+ function sourceCode(state) {
26
+ return String(state.source?.languageCode ?? "");
27
+ }
28
+ const APPROVAL_POLL_MS = 10 * 1000;
29
+ const SETTLE_POLL_MS = 3 * 1000;
30
+ const SETTLE_MAX_MS = 60 * 1000;
31
+ const WAITING_STATES = ["PendingReview", "UnAvailable", "missing"];
32
+ function iconState(list, languageCode) {
33
+ const entry = (list?.data ?? []).find((icon) => icon.languageCode === languageCode);
34
+ return entry ? String(entry.state) : "missing";
35
+ }
36
+ async function waitForApproval(options, languageCode) {
37
+ const started = Date.now();
38
+ let last = "";
39
+ while (Date.now() - started < options.approvalWaitMs && !options.shouldStop()) {
40
+ const state = iconState(await options.cloud.listIcons(), languageCode);
41
+ if (state !== last) {
42
+ options.log(`[swap] ${languageCode} icon is ${state} after ${Math.round((Date.now() - started) / 1000)}s`);
43
+ last = state;
44
+ }
45
+ if (!WAITING_STATES.includes(state)) {
46
+ return state;
47
+ }
48
+ await new Promise((done) => setTimeout(done, APPROVAL_POLL_MS));
49
+ }
50
+ options.log(`[swap] gave up waiting for review after ${Math.round((Date.now() - started) / 1000)}s`);
51
+ return last;
52
+ }
53
+ async function settledSnapshot(universeId, expectedSource) {
54
+ const started = Date.now();
55
+ let state = await snapshot(universeId);
56
+ while (sourceCode(state) !== expectedSource && Date.now() - started < SETTLE_MAX_MS) {
57
+ await new Promise((done) => setTimeout(done, SETTLE_POLL_MS));
58
+ state = await snapshot(universeId);
59
+ }
60
+ return state;
61
+ }
62
+ async function hold(options) {
63
+ for (let waited = 0; waited < options.holdMs && !options.shouldStop(); waited += 1000) {
64
+ await new Promise((done) => setTimeout(done, 1000));
65
+ }
66
+ }
67
+ export async function languageSwapExperiment(options) {
68
+ const { cloud, universeId, log } = options;
69
+ const before = await snapshot(universeId);
70
+ const original = sourceCode(before);
71
+ log(`[swap] source language before: ${original}`);
72
+ log(`[swap] icons before: ${JSON.stringify(await cloud.listIcons())}`);
73
+ if (original === options.swapTo) {
74
+ throw new Error(`Source is already ${options.swapTo}; pick another language to swap to`);
75
+ }
76
+ let swapped = false;
77
+ let uploaded = false;
78
+ try {
79
+ await cloud.setSourceLanguage(options.swapTo);
80
+ swapped = true;
81
+ log(`[swap] source language now: ${sourceCode(await snapshot(universeId))}`);
82
+ await cloud.setIcon(options.icon, original);
83
+ uploaded = true;
84
+ log(`[swap] uploaded test icon as the ${original} translation`);
85
+ log(`[swap] icons now: ${JSON.stringify(await cloud.listIcons())}`);
86
+ const state = await waitForApproval(options, original);
87
+ if (state === "Approved") {
88
+ log(`[swap] public icon now: ${JSON.stringify(await publicIcon(universeId))}`);
89
+ }
90
+ log(`[swap] holding ${Math.round(options.holdMs / 1000)}s: look at the game page now`);
91
+ await hold(options);
92
+ }
93
+ finally {
94
+ if (uploaded) {
95
+ await cloud.deleteIcon(original).then(() => log(`[swap] removed the ${original} translation icon`), (error) => log(`[swap] FAILED to remove ${original} icon: ${error.message}`));
96
+ }
97
+ if (swapped) {
98
+ await cloud.setSourceLanguage(original).then(() => log(`[swap] source language restored to ${original}`), (error) => log(`[swap] FAILED to restore source language: ${error.message}`));
99
+ }
100
+ const after = await settledSnapshot(universeId, original);
101
+ log(`[swap] icons after: ${JSON.stringify(await cloud.listIcons().catch((error) => error.message))}`);
102
+ for (const key of Object.keys(before)) {
103
+ const same = canonical(before[key]) === canonical(after[key]);
104
+ log(`[swap] ${key}: ${same ? "unchanged" : `CHANGED\n before ${JSON.stringify(before[key])}\n after ${JSON.stringify(after[key])}`}`);
105
+ }
106
+ }
107
+ }
108
+ async function waitMs(ctx, ms) {
109
+ for (let waited = 0; waited < ms && !ctx.shouldStop(); waited += 1000) {
110
+ await new Promise((done) => setTimeout(done, 1000));
111
+ }
112
+ }
113
+ export async function nameChange(ctx) {
114
+ const { cloud, universeId, log } = ctx;
115
+ const universe = await cloud.request("GET", `/cloud/v2/universes/${universeId}`, "read universe");
116
+ const placeId = Number(String(universe.rootPlace).split("/").pop());
117
+ const placePath = `/cloud/v2/universes/${universeId}/places/${placeId}`;
118
+ const before = await cloud.request("GET", placePath, "read place");
119
+ const translationsBefore = await publicJson(`/v1/name-description/games/${universeId}`);
120
+ log(`[name] universe displayName: ${JSON.stringify(universe.displayName)}`);
121
+ log(`[name] root place ${placeId}: ${JSON.stringify(before.displayName)} / ${JSON.stringify(String(before.description ?? "").slice(0, 60))}`);
122
+ const testName = ctx.params.get("testName") || `[TEST] ${before.displayName}`;
123
+ const testDescription = `${before.description ?? ""}\n\nUpdateDay test ${new Date().toISOString()}`;
124
+ let changed = false;
125
+ try {
126
+ const updated = await cloud.json("PATCH", `${placePath}?updateMask=displayName,description`, "set name", { displayName: testName, description: testDescription });
127
+ changed = true;
128
+ log(`[name] wrote: ${JSON.stringify(updated.displayName)}`);
129
+ const universeAfter = await cloud.request("GET", `/cloud/v2/universes/${universeId}`, "read universe");
130
+ log(`[name] universe displayName now: ${JSON.stringify(universeAfter.displayName)}`);
131
+ log(`[name] translations now: ${JSON.stringify(await publicJson(`/v1/name-description/games/${universeId}`)).slice(0, 200)}`);
132
+ const holdSeconds = Number(ctx.params.get("hold")) || 120;
133
+ log(`[name] holding ${holdSeconds}s: check the game page now`);
134
+ await waitMs(ctx, holdSeconds * 1000);
135
+ }
136
+ finally {
137
+ if (changed) {
138
+ await cloud.json("PATCH", `${placePath}?updateMask=displayName,description`, "restore name", { displayName: before.displayName, description: before.description ?? "" }).then(() => log("[name] restored name and description"), (error) => log(`[name] FAILED to restore: ${error.message}`));
139
+ }
140
+ const after = await cloud.request("GET", placePath, "read place");
141
+ const same = after.displayName === before.displayName && (after.description ?? "") === (before.description ?? "");
142
+ log(`[name] place after: ${same ? "matches original" : `DIFFERS ${JSON.stringify(after.displayName)}`}`);
143
+ const translationsAfter = await publicJson(`/v1/name-description/games/${universeId}`);
144
+ log(`[name] translations: ${canonical(translationsBefore) === canonical(translationsAfter) ? "unchanged" : "CHANGED"}`);
145
+ }
146
+ }
147
+ export async function clearTestDescriptions(ctx) {
148
+ const { cloud, universeId, log } = ctx;
149
+ const marker = ctx.params.get("marker") ?? "";
150
+ if (!marker) {
151
+ throw new Error("Pass the marker text that identifies the test descriptions");
152
+ }
153
+ const current = (await publicJson(`/v1/name-description/games/${universeId}`));
154
+ const targets = (current.data ?? []).filter((entry) => String(entry.description ?? "").includes(marker));
155
+ log(`[clear] ${targets.length} languages carry the test description`);
156
+ if (targets.length === 0) {
157
+ return;
158
+ }
159
+ const data = targets.map((entry) => ({ description: "", updateType: "Description", languageCode: entry.languageCode }));
160
+ await cloud.json("PATCH", `/legacy-game-internationalization/v1/name-description/games/${universeId}`, "clear descriptions", { data });
161
+ await new Promise((done) => setTimeout(done, 3000));
162
+ const after = (await publicJson(`/v1/name-description/games/${universeId}`));
163
+ const left = (after.data ?? []).filter((entry) => String(entry.description ?? "").includes(marker));
164
+ log(`[clear] languages still carrying it: ${left.length ? left.map((entry) => entry.languageCode).join(", ") : "none"}`);
165
+ }
166
+ export async function listShapes(ctx) {
167
+ const { cloud, universeId, log } = ctx;
168
+ const passes = await cloud.request("GET", `/game-passes/v1/universes/${universeId}/game-passes/creator?pageSize=2`, "list passes");
169
+ log(`[shape] passes keys: ${Object.keys(passes).join(",")}`);
170
+ log(`[shape] pass item: ${JSON.stringify((passes.gamePasses ?? passes.data ?? [])[0])}`);
171
+ const products = await cloud.request("GET", `/developer-products/v2/universes/${universeId}/developer-products/creator?pageSize=2`, "list products");
172
+ log(`[shape] products keys: ${Object.keys(products).join(",")}`);
173
+ log(`[shape] product item: ${JSON.stringify((products.developerProducts ?? products.data ?? [])[0])}`);
174
+ }
175
+ export async function pageShapes(ctx) {
176
+ const { cloud, universeId, log } = ctx;
177
+ const events = await cloud.request("GET", `/virtual-events/v3/universes/${universeId}/game-events`, "list events");
178
+ log(`[page] events keys: ${Object.keys(events ?? {}).join(",")}`);
179
+ log(`[page] event item: ${JSON.stringify((events?.gameEvents ?? events?.data ?? events?.events ?? [])[0]).slice(0, 900)}`);
180
+ const universe = await cloud.request("GET", `/cloud/v2/universes/${universeId}`, "read universe");
181
+ log(`[page] universe: ${JSON.stringify(universe).slice(0, 600)}`);
182
+ log(`[page] thumbnails: ${JSON.stringify(await publicJson(`/v1/game-thumbnails/games/${universeId}/images`)).slice(0, 400)}`);
183
+ const games = await fetch(`https://games.roblox.com/v1/games?universeIds=${universeId}`).then((r) => r.json());
184
+ log(`[page] games api: ${JSON.stringify(games).slice(0, 300)}`);
185
+ }
186
+ export async function eventCategories(ctx) {
187
+ const { cloud, universeId, log } = ctx;
188
+ const list = await cloud.request("GET", `/virtual-events/v3/universes/${universeId}/game-events`, "list events");
189
+ const event = (list?.gameEvents ?? []).find((item) => item.title === "UpdateDay events test");
190
+ if (!event) {
191
+ log("[cat] test event not found");
192
+ return;
193
+ }
194
+ log(`[cat] test event ${event.id}, categories now ${JSON.stringify(event.categories)}`);
195
+ const tries = [
196
+ [{ category: "newContent", rank: 0 }],
197
+ [{ category: "NewContent", rank: 0 }],
198
+ [{ category: "NewContent", rank: 1 }],
199
+ [{ category: "newContent", rank: 1 }, { category: "newFeature", rank: 2 }],
200
+ [{ category: "contentUpdate", rank: 0 }],
201
+ ];
202
+ for (const categories of tries) {
203
+ try {
204
+ const result = await cloud.json("PATCH", `/virtual-events/v3/game-events/${event.id}`, "patch categories", { categories });
205
+ log(`[cat] OK ${JSON.stringify(categories)} -> ${JSON.stringify(result?.categories)}`);
206
+ }
207
+ catch (error) {
208
+ log(`[cat] NO ${JSON.stringify(categories)}: ${String(error.message).slice(0, 160)}`);
209
+ }
210
+ }
211
+ if (ctx.params.get("delete") === "1") {
212
+ await cloud.request("DELETE", `/virtual-events/v3/game-events/${event.id}`, "delete event");
213
+ log("[cat] deleted the test event");
214
+ }
215
+ }
216
+ export async function eventCategoryList(ctx) {
217
+ const { cloud, universeId, log } = ctx;
218
+ const list = await cloud.request("GET", `/virtual-events/v3/universes/${universeId}/game-events`, "list events");
219
+ const event = (list?.gameEvents ?? []).find((item) => item.title === "UpdateDay live test");
220
+ if (!event) {
221
+ log("[cats] live test event not found");
222
+ return;
223
+ }
224
+ const all = ["contentUpdate", "locationUpdate", "systemUpdate", "activity", "newContent", "itemDrop", "newSeason", "newLocation", "newMap", "moreLevels", "newFeature", "earlyAccess", "expansion", "challenge", "quest", "festival"];
225
+ const accepted = [];
226
+ const refused = [];
227
+ for (const category of all) {
228
+ try {
229
+ await cloud.json("PATCH", `/virtual-events/v3/game-events/${event.id}`, "patch", { categories: [{ category, rank: 0 }] });
230
+ accepted.push(category);
231
+ }
232
+ catch {
233
+ refused.push(category);
234
+ }
235
+ }
236
+ await cloud.json("PATCH", `/virtual-events/v3/game-events/${event.id}`, "reset", { categories: [] }).catch((error) => log(`[cats] reset failed: ${error.message.slice(0, 120)}`));
237
+ log(`[cats] accepted: ${accepted.join(", ")}`);
238
+ log(`[cats] refused: ${refused.join(", ")}`);
239
+ }
240
+ export async function iconUrls(ctx) {
241
+ const list = await ctx.cloud.listIcons();
242
+ for (const icon of list?.data ?? []) {
243
+ ctx.log(`[icons] ${icon.languageCode} ${icon.state} ${icon.imageUrl ?? ""}`);
244
+ }
245
+ }
246
+ async function freshImage(label) {
247
+ const sharp = (await import("sharp")).default;
248
+ const stamp = new Date().toISOString();
249
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512">
250
+ <rect width="512" height="512" fill="#1d2633"/>
251
+ <text x="256" y="250" text-anchor="middle" font-family="Arial Black, Arial" font-weight="900" font-size="88" fill="#ffffff">${label}</text>
252
+ <text x="256" y="330" text-anchor="middle" font-family="Arial" font-size="22" fill="#8fa3bf">${stamp}</text>
253
+ </svg>`;
254
+ const png = await sharp(Buffer.from(svg)).png().toBuffer();
255
+ return new Blob([new Uint8Array(png)], { type: "image/png" });
256
+ }
257
+ async function assetState(ctx, assetId) {
258
+ const asset = await ctx.cloud.request("GET", `/assets/v1/assets/${assetId}?readMask=moderationResult`, "read asset");
259
+ return String(asset?.moderationResult?.moderationState ?? "unknown");
260
+ }
261
+ async function iconStates(ctx) {
262
+ const states = {};
263
+ for (const icon of (await ctx.cloud.listIcons())?.data ?? []) {
264
+ states[icon.languageCode] = icon.state;
265
+ }
266
+ return states;
267
+ }
268
+ export async function preReview(ctx) {
269
+ const { cloud, log } = ctx;
270
+ const reviewed = await freshImage("TEST A");
271
+ const unreviewed = await freshImage("TEST B");
272
+ const started = Date.now();
273
+ const seconds = () => Math.round((Date.now() - started) / 1000);
274
+ const assetId = await cloud.uploadImage(reviewed, "UpdateDay pre-review test");
275
+ log(`[pre] uploaded image A as asset ${assetId} after ${seconds()}s`);
276
+ let state = "";
277
+ const assetWaitMs = (Number(ctx.params.get("assetWait")) || 600) * 1000;
278
+ while (Date.now() - started < assetWaitMs && !ctx.shouldStop()) {
279
+ const now = await assetState(ctx, assetId);
280
+ if (now !== state) {
281
+ log(`[pre] asset A is ${now} at ${seconds()}s`);
282
+ state = now;
283
+ }
284
+ if (now !== "Reviewing" && now !== "unknown") {
285
+ break;
286
+ }
287
+ await waitMs(ctx, 5000);
288
+ }
289
+ const iconStart = Date.now();
290
+ await cloud.setIcon(reviewed, "es");
291
+ await cloud.setIcon(unreviewed, "de");
292
+ log("[pre] set A (pre-reviewed) as the es icon and B (fresh) as the de icon");
293
+ const seen = {};
294
+ const iconWaitMs = (Number(ctx.params.get("iconWait")) || 300) * 1000;
295
+ try {
296
+ while (Date.now() - iconStart < iconWaitMs && !ctx.shouldStop()) {
297
+ const states = await iconStates(ctx);
298
+ for (const code of ["es", "de"]) {
299
+ if (states[code] !== seen[code]) {
300
+ log(`[pre] ${code} icon is ${states[code]} ${Math.round((Date.now() - iconStart) / 1000)}s after upload`);
301
+ seen[code] = states[code];
302
+ }
303
+ }
304
+ if (seen.es === "Approved" && seen.de === "Approved") {
305
+ break;
306
+ }
307
+ await waitMs(ctx, 5000);
308
+ }
309
+ }
310
+ finally {
311
+ for (const code of ["es", "de"]) {
312
+ await cloud.deleteIcon(code).then(() => log(`[pre] removed the ${code} test icon`), (error) => log(`[pre] FAILED to remove ${code} icon: ${error.message}`));
313
+ }
314
+ }
315
+ }
316
+ export async function stageTiming(ctx) {
317
+ const { cloud, log } = ctx;
318
+ const source = String((await publicJson(`/v1/source-language/games/${ctx.universeId}`)).languageCode);
319
+ const supported = ((await publicJson(`/v1/supported-languages/games/${ctx.universeId}`)).data ?? []).map((item) => item.languageCode).filter((code) => code !== source);
320
+ const image = await freshImage("STAGE");
321
+ const started = Date.now();
322
+ for (const code of supported) {
323
+ await cloud.setIcon(image, code);
324
+ await waitMs(ctx, 250);
325
+ }
326
+ log(`[stage] uploaded to ${supported.length} languages in ${Math.round((Date.now() - started) / 1000)}s`);
327
+ const approvedAt = {};
328
+ try {
329
+ while (Date.now() - started < 600000 && !ctx.shouldStop() && Object.keys(approvedAt).length < supported.length) {
330
+ const states = await iconStates(ctx);
331
+ for (const code of supported) {
332
+ if (!approvedAt[code] && states[code] === "Approved") {
333
+ approvedAt[code] = Math.round((Date.now() - started) / 1000);
334
+ }
335
+ }
336
+ await waitMs(ctx, 5000);
337
+ }
338
+ const times = Object.values(approvedAt).sort((a, b) => a - b);
339
+ log(`[stage] approved ${times.length}/${supported.length}; first ${times[0]}s, median ${times[Math.floor(times.length / 2)]}s, last ${times[times.length - 1]}s`);
340
+ log(`[stage] per language: ${JSON.stringify(approvedAt)}`);
341
+ }
342
+ finally {
343
+ for (const code of supported) {
344
+ await cloud.deleteIcon(code).catch((error) => log(`[stage] FAILED to remove ${code}: ${error.message}`));
345
+ await waitMs(ctx, 250);
346
+ }
347
+ log(`[stage] removed the test icons; left: ${JSON.stringify(Object.keys(await iconStates(ctx)))}`);
348
+ }
349
+ }
@@ -0,0 +1,143 @@
1
+ const GAMES_API = "https://games.roblox.com/v1/games";
2
+ const ICON_API = "https://thumbnails.roblox.com/v1/games/icons";
3
+ const ASSET_THUMBS_API = "https://thumbnails.roblox.com/v1/assets";
4
+ const I18N_API = "https://gameinternationalization.roblox.com/v1/game-thumbnails/games";
5
+ const USERS_API = "https://users.roblox.com/v1/users";
6
+ const GROUPS_API = "https://groups.roblox.com/v1/groups";
7
+ const RECOMMENDED_THUMBNAILS = 3;
8
+ const EVENT_WINDOW_MS = 24 * 60 * 60 * 1000;
9
+ const UPDATE_TAG = /\[[^\]]*update[^\]]*\]|\bupd(ate)?\b|🆕|🎃|🔥|✨|⭐/i;
10
+ const PLACEHOLDER = /^\[.*UNAVAILABLE\]$/;
11
+ async function getJson(url) {
12
+ const response = await fetch(url);
13
+ return response.ok ? response.json() : null;
14
+ }
15
+ async function publicDetails(universeId) {
16
+ const [games, votes] = await Promise.all([
17
+ getJson(`${GAMES_API}?universeIds=${universeId}`),
18
+ getJson(`${GAMES_API}/votes?universeIds=${universeId}`),
19
+ ]);
20
+ const game = games?.data?.[0];
21
+ if (!game || PLACEHOLDER.test(String(game.name))) {
22
+ return null;
23
+ }
24
+ const vote = votes?.data?.[0];
25
+ const totalVotes = (vote?.upVotes ?? 0) + (vote?.downVotes ?? 0);
26
+ return { ...game, likeRatio: totalVotes ? Math.round((vote.upVotes / totalVotes) * 100) : null };
27
+ }
28
+ async function iconUrl(universeId) {
29
+ const json = await getJson(`${ICON_API}?universeIds=${universeId}&size=512x512&format=Png&isCircular=false`);
30
+ return json?.data?.[0]?.imageUrl ?? null;
31
+ }
32
+ async function thumbnailUrls(universeId, languageCode) {
33
+ const json = await getJson(`${I18N_API}/${universeId}/images`);
34
+ const entry = (json?.data ?? []).find((item) => item.languageCode === languageCode) ?? json?.data?.[0];
35
+ const assetIds = (entry?.mediaAssets ?? []).map((asset) => Number(asset.mediaAssetId ?? asset.id ?? asset)).filter(Boolean);
36
+ if (assetIds.length === 0) {
37
+ return [];
38
+ }
39
+ const thumbs = await getJson(`${ASSET_THUMBS_API}?assetIds=${assetIds.join(",")}&size=768x432&format=Png`);
40
+ return (thumbs?.data ?? []).map((item) => item.imageUrl).filter(Boolean);
41
+ }
42
+ async function creatorName(owner) {
43
+ const match = /^(users|groups)\/(\d+)$/.exec(owner ?? "");
44
+ if (!match) {
45
+ return null;
46
+ }
47
+ const json = await getJson(`${match[1] === "users" ? USERS_API : GROUPS_API}/${match[2]}`);
48
+ return json?.name ? (match[1] === "groups" ? `${json.name} (group)` : `@${json.name}`) : null;
49
+ }
50
+ function devicesOf(universe) {
51
+ const names = {
52
+ desktopEnabled: "Computer",
53
+ mobileEnabled: "Phone",
54
+ tabletEnabled: "Tablet",
55
+ consoleEnabled: "Console",
56
+ vrEnabled: "VR",
57
+ };
58
+ return Object.entries(names).filter(([key]) => universe?.[key]).map(([, label]) => label);
59
+ }
60
+ function eventsNearUpdate(events, updateAt) {
61
+ return events.filter((event) => Math.abs(Date.parse(event.startTime) - updateAt.getTime()) <= EVENT_WINDOW_MS);
62
+ }
63
+ function buildChecks(config, report, updatedAt) {
64
+ const checks = [];
65
+ const updateAt = new Date(config.updateAt);
66
+ if (report.visibility === "PRIVATE") {
67
+ checks.push({ level: "warn", title: "The game is private", detail: "Players can't see the countdown, sale or event until it's public." });
68
+ }
69
+ if (report.thumbnails.length < RECOMMENDED_THUMBNAILS) {
70
+ checks.push({
71
+ level: "warn",
72
+ title: report.thumbnails.length === 0 ? "No thumbnails" : `Only ${report.thumbnails.length} thumbnail${report.thumbnails.length === 1 ? "" : "s"}`,
73
+ detail: "Game pages usually show 3 or more; the first one is the one most players see.",
74
+ });
75
+ }
76
+ else {
77
+ checks.push({ level: "ok", title: `${report.thumbnails.length} thumbnails` });
78
+ }
79
+ if (!report.description.trim()) {
80
+ checks.push({ level: "warn", title: "No description", detail: "Say what's new in this update; it shows on the game page." });
81
+ }
82
+ const nearby = eventsNearUpdate(report.events, updateAt);
83
+ if (nearby.length > 0) {
84
+ checks.push({ level: "ok", title: `Event for the update: ${nearby[0].title}` });
85
+ }
86
+ else if (config.event) {
87
+ checks.push({ level: "info", title: "Event will be created", detail: `UpdateDay creates "${config.event.title}" when it starts.` });
88
+ }
89
+ else {
90
+ checks.push({ level: "warn", title: "No event for this update", detail: "Events show on the game page and in players' calendars." });
91
+ }
92
+ const plannedRename = (config.nameChanges ?? []).some((change) => change.name);
93
+ if (UPDATE_TAG.test(report.name)) {
94
+ checks.push({ level: "ok", title: "Title has an update tag" });
95
+ }
96
+ else if (plannedRename) {
97
+ checks.push({ level: "ok", title: "A title change is scheduled" });
98
+ }
99
+ else {
100
+ checks.push({ level: "info", title: "Title has no update tag", detail: "Many games add [UPDATE] or an emoji to the title while the update is fresh." });
101
+ }
102
+ if (updatedAt) {
103
+ const days = Math.floor((Date.now() - Date.parse(updatedAt)) / EVENT_WINDOW_MS);
104
+ checks.push({ level: "info", title: days <= 0 ? "Last update today" : `Last update ${days} day${days === 1 ? "" : "s"} ago` });
105
+ }
106
+ if (config.sale) {
107
+ checks.push({ level: "ok", title: `Sale planned: ${config.sale.percentOff}% off` });
108
+ }
109
+ return checks;
110
+ }
111
+ export async function pageReport(config, cloud) {
112
+ const [details, icon, thumbnails] = await Promise.all([
113
+ publicDetails(config.universeId),
114
+ iconUrl(config.universeId),
115
+ thumbnailUrls(config.universeId, config.languageCode),
116
+ ]);
117
+ const universe = cloud ? await cloud.getUniverse().catch(() => null) : null;
118
+ const events = cloud ? await cloud.request("GET", `/virtual-events/v3/universes/${config.universeId}/game-events`, "list events").then((json) => json?.gameEvents ?? []).catch(() => []) : [];
119
+ const report = {
120
+ name: String(universe?.displayName ?? details?.name ?? config.name),
121
+ description: String(universe?.description ?? details?.description ?? ""),
122
+ creator: universe ? await creatorName(universe.user ?? universe.group) : details?.creator?.name ?? null,
123
+ visibility: universe?.visibility ?? (details ? "PUBLIC" : null),
124
+ ageRating: universe?.ageRating ?? null,
125
+ devices: devicesOf(universe),
126
+ iconUrl: icon,
127
+ thumbnails,
128
+ stats: details ? {
129
+ playing: details.playing ?? null,
130
+ visits: details.visits ?? null,
131
+ favorites: details.favoritedCount ?? null,
132
+ liked: details.likeRatio,
133
+ serverSize: details.maxPlayers ?? null,
134
+ genre: details.genre ?? null,
135
+ updated: details.updated ?? null,
136
+ } : null,
137
+ events,
138
+ checks: [],
139
+ keyUsed: Boolean(cloud),
140
+ };
141
+ report.checks = buildChecks(config, report, details?.updated ?? null);
142
+ return report;
143
+ }
package/dist/gifts.js ADDED
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readEntry, saveEntry } from "./playerData.js";
3
+ export const DEFAULT_GIFT_STORE = "UpdateDayGifts";
4
+ export const GIFT_TOPIC = "UpdateDayGifts";
5
+ const MAX_AMOUNT = 50;
6
+ const SAVE_ATTEMPTS = 3;
7
+ function giftsOf(value) {
8
+ const gifts = value?.gifts;
9
+ return Array.isArray(gifts) ? gifts : [];
10
+ }
11
+ function validate(input) {
12
+ if (!input.category?.trim() || !input.item?.trim()) {
13
+ throw new Error("Pick what to give");
14
+ }
15
+ if (!Number.isInteger(input.amount) || input.amount < 1 || input.amount > MAX_AMOUNT) {
16
+ throw new Error(`Amount must be 1 to ${MAX_AMOUNT}`);
17
+ }
18
+ }
19
+ export async function pendingGifts(cloud, store, userId) {
20
+ const entry = await readEntry(cloud, store, String(userId));
21
+ return giftsOf(entry?.value);
22
+ }
23
+ async function changeGifts(cloud, baseDir, store, userId, reason, change) {
24
+ let lastError = null;
25
+ for (let attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) {
26
+ const entry = await readEntry(cloud, store, String(userId));
27
+ const next = change(giftsOf(entry?.value));
28
+ try {
29
+ await saveEntry(cloud, baseDir, store, String(userId), { gifts: next }, entry?.etag ?? null, reason);
30
+ return next;
31
+ }
32
+ catch (error) {
33
+ lastError = error;
34
+ }
35
+ }
36
+ throw lastError;
37
+ }
38
+ export async function sendGift(cloud, baseDir, store, userId, input) {
39
+ validate(input);
40
+ const gift = {
41
+ id: randomUUID(),
42
+ category: input.category.trim(),
43
+ item: input.item.trim(),
44
+ tier: input.tier?.trim() || null,
45
+ amount: input.amount,
46
+ sentAt: new Date().toISOString(),
47
+ note: input.note?.trim() || null,
48
+ };
49
+ await changeGifts(cloud, baseDir, store, userId, `gift ${gift.amount}x ${gift.item}`, (gifts) => [...gifts, gift]);
50
+ await cloud.publishMessage(GIFT_TOPIC, JSON.stringify({ userId })).catch(() => null);
51
+ return gift;
52
+ }
53
+ export async function cancelGift(cloud, baseDir, store, userId, giftId) {
54
+ return changeGifts(cloud, baseDir, store, userId, `cancel gift ${giftId}`, (gifts) => gifts.filter((gift) => gift.id !== giftId));
55
+ }
@@ -0,0 +1,68 @@
1
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { setLogSink } from "./state.js";
4
+ const HISTORY_FILE = "updateday-history.jsonl";
5
+ const DEFAULT_LIMIT = 500;
6
+ export function historyPath(baseDir) {
7
+ return join(baseDir, HISTORY_FILE);
8
+ }
9
+ export function kindOf(text) {
10
+ if (/^(FAILED|ERROR)\b|failed|refused|couldn't/i.test(text)) {
11
+ return "error";
12
+ }
13
+ if (/^RUN\b/.test(text)) {
14
+ return "step";
15
+ }
16
+ if (/^SKIP\b/.test(text)) {
17
+ return "skip";
18
+ }
19
+ if (/^REMINDER\b/.test(text)) {
20
+ return "reminder";
21
+ }
22
+ if (/->|restored|renamed|name restored/.test(text)) {
23
+ return "sale";
24
+ }
25
+ return "info";
26
+ }
27
+ export function appendHistory(baseDir, config, text) {
28
+ const clean = text.trim();
29
+ if (!clean) {
30
+ return;
31
+ }
32
+ const entry = { at: new Date().toISOString(), game: config.name, universeId: config.universeId, kind: kindOf(clean), text: clean };
33
+ try {
34
+ appendFileSync(historyPath(baseDir), JSON.stringify(entry) + "\n");
35
+ }
36
+ catch {
37
+ }
38
+ }
39
+ export async function recording(config, task) {
40
+ setLogSink((message) => appendHistory(config.baseDir, config, message));
41
+ try {
42
+ return await task();
43
+ }
44
+ finally {
45
+ setLogSink(null);
46
+ }
47
+ }
48
+ export function readHistory(baseDir, universeId, limit = DEFAULT_LIMIT) {
49
+ const path = historyPath(baseDir);
50
+ if (!existsSync(path)) {
51
+ return [];
52
+ }
53
+ const entries = [];
54
+ for (const line of readFileSync(path, "utf8").split("\n")) {
55
+ if (!line.trim()) {
56
+ continue;
57
+ }
58
+ try {
59
+ const entry = JSON.parse(line);
60
+ if (universeId === undefined || entry.universeId === universeId) {
61
+ entries.push(entry);
62
+ }
63
+ }
64
+ catch {
65
+ }
66
+ }
67
+ return entries.slice(-limit).reverse();
68
+ }