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.
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/assets/hourglass/10m.png +0 -0
- package/assets/hourglass/12h.png +0 -0
- package/assets/hourglass/15m.png +0 -0
- package/assets/hourglass/1h.png +0 -0
- package/assets/hourglass/24h.png +0 -0
- package/assets/hourglass/2h.png +0 -0
- package/assets/hourglass/30m.png +0 -0
- package/assets/hourglass/3h.png +0 -0
- package/assets/hourglass/45m.png +0 -0
- package/assets/hourglass/4h.png +0 -0
- package/assets/hourglass/5h.png +0 -0
- package/assets/hourglass/5m.png +0 -0
- package/assets/hourglass/6h.png +0 -0
- package/assets/hourglass/default.png +0 -0
- package/assets/hourglass/now.png +0 -0
- package/assets/hourglass-styles/blocky.png +0 -0
- package/assets/hourglass-styles/classic.png +0 -0
- package/assets/hourglass-styles/glow.png +0 -0
- package/assets/hourglass-styles/outline.png +0 -0
- package/assets/hourglass-styles/pixel.png +0 -0
- package/assets/hourglass-styles/rounded.png +0 -0
- package/bin/updateday.js +5 -0
- package/dist/bundle.js +54 -0
- package/dist/cli.js +254 -0
- package/dist/config.js +261 -0
- package/dist/doctor.js +119 -0
- package/dist/experiments.js +349 -0
- package/dist/gamePage.js +143 -0
- package/dist/gifts.js +55 -0
- package/dist/history.js +68 -0
- package/dist/icons.js +216 -0
- package/dist/labels.js +64 -0
- package/dist/mcp.js +302 -0
- package/dist/moderation.js +86 -0
- package/dist/notify.js +72 -0
- package/dist/openCloud.js +211 -0
- package/dist/playerData.js +109 -0
- package/dist/preflight.js +202 -0
- package/dist/runner.js +360 -0
- package/dist/schedule.js +162 -0
- package/dist/server.js +929 -0
- package/dist/state.js +29 -0
- package/dist/swap.js +196 -0
- package/dist/zip.js +82 -0
- package/docs/AGENTS.md +123 -0
- package/docs/COVERAGE.md +50 -0
- package/docs/GUIDE.md +288 -0
- package/docs/hourglass-styles.png +0 -0
- package/docs/screenshot.png +0 -0
- package/example/multi-game.json +16 -0
- package/example/updateday.json +37 -0
- package/package.json +63 -5
- package/roblox/UpdateDayBanner.client.luau +159 -0
- package/roblox/UpdateDayCountdown.server.luau +48 -0
- package/roblox/UpdateDayGifts.luau +119 -0
- package/roblox/UpdateDayListener.luau +43 -0
- package/templates/github-actions/updateday.yml +35 -0
- package/ui/core.js +325 -0
- package/ui/countdown.js +775 -0
- package/ui/events.js +249 -0
- package/ui/gifts.js +215 -0
- package/ui/history.js +156 -0
- package/ui/index.html +530 -0
- package/ui/jsonEditor.js +417 -0
- package/ui/page.js +94 -0
- package/ui/players.js +256 -0
- package/ui/run.js +391 -0
- package/ui/sale.js +366 -0
- package/ui/settings.js +414 -0
- package/ui/shell.js +193 -0
package/dist/notify.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const WEBHOOK_PATTERN = /^https:\/\/(?:(?:canary|ptb)\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+$/;
|
|
2
|
+
const BOT_NAME = "UpdateDay";
|
|
3
|
+
const IMAGE_NAME = "icon.png";
|
|
4
|
+
const COLORS = {
|
|
5
|
+
reminders: 0xf0b232,
|
|
6
|
+
failures: 0xf0555a,
|
|
7
|
+
live: 0x2fbf71,
|
|
8
|
+
finished: 0x4c8dff,
|
|
9
|
+
steps: 0x8b93a7,
|
|
10
|
+
};
|
|
11
|
+
export function isWebhookUrl(url) {
|
|
12
|
+
return WEBHOOK_PATTERN.test(url.trim());
|
|
13
|
+
}
|
|
14
|
+
function allowedMentions(mention) {
|
|
15
|
+
if (!mention) {
|
|
16
|
+
return { parse: [] };
|
|
17
|
+
}
|
|
18
|
+
return { parse: ["everyone", "roles", "users"] };
|
|
19
|
+
}
|
|
20
|
+
export async function postDiscord(webhook, message, mention) {
|
|
21
|
+
if (!isWebhookUrl(webhook)) {
|
|
22
|
+
throw new Error("That isn't a Discord webhook link (https://discord.com/api/webhooks/...)");
|
|
23
|
+
}
|
|
24
|
+
const embed = {
|
|
25
|
+
title: message.title,
|
|
26
|
+
description: message.text,
|
|
27
|
+
color: COLORS[message.event],
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
const payload = {
|
|
31
|
+
username: BOT_NAME,
|
|
32
|
+
content: mention ?? "",
|
|
33
|
+
embeds: [embed],
|
|
34
|
+
allowed_mentions: allowedMentions(mention),
|
|
35
|
+
};
|
|
36
|
+
let body;
|
|
37
|
+
if (message.image) {
|
|
38
|
+
embed.image = { url: `attachment://${IMAGE_NAME}` };
|
|
39
|
+
const form = new FormData();
|
|
40
|
+
form.set("payload_json", JSON.stringify(payload));
|
|
41
|
+
form.set("files[0]", new Blob([new Uint8Array(message.image)], { type: "image/png" }), IMAGE_NAME);
|
|
42
|
+
body = form;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
body = JSON.stringify(payload);
|
|
46
|
+
}
|
|
47
|
+
const response = await fetch(webhook.trim(), {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: message.image ? undefined : { "content-type": "application/json" },
|
|
50
|
+
body,
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new Error(`Discord refused the message (${response.status})`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function discordNotifier(config, webhook) {
|
|
57
|
+
const settings = config.notify;
|
|
58
|
+
if (!settings || !webhook) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return async (message) => {
|
|
62
|
+
if (!settings.events.includes(message.event)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await postDiscord(webhook, { ...message, title: `${config.name}: ${message.title}` }, settings.mention);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
console.error(`Discord alert not sent: ${error instanceof Error ? error.message : error}`);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const BASE_URL = "https://apis.roblox.com";
|
|
2
|
+
const MAX_RETRIES = 4;
|
|
3
|
+
const RETRY_BASE_MS = 1000;
|
|
4
|
+
const UPLOAD_POLLS = 20;
|
|
5
|
+
const UPLOAD_POLL_MS = 1500;
|
|
6
|
+
export class OpenCloudError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
body;
|
|
9
|
+
constructor(status, body, label) {
|
|
10
|
+
super(`${label} failed (${status}): ${body.slice(0, 300)}`);
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.body = body;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
function isRetryable(status) {
|
|
19
|
+
return status === 429 || status >= 500;
|
|
20
|
+
}
|
|
21
|
+
function priceBasePath(universeId, target) {
|
|
22
|
+
if (target.kind === "gamePass") {
|
|
23
|
+
return `/game-passes/v1/universes/${universeId}/game-passes/${target.id}`;
|
|
24
|
+
}
|
|
25
|
+
return `/developer-products/v2/universes/${universeId}/developer-products/${target.id}`;
|
|
26
|
+
}
|
|
27
|
+
function listPath(universeId, kind) {
|
|
28
|
+
if (kind === "gamePass") {
|
|
29
|
+
return `/game-passes/v1/universes/${universeId}/game-passes/creator`;
|
|
30
|
+
}
|
|
31
|
+
return `/developer-products/v2/universes/${universeId}/developer-products/creator`;
|
|
32
|
+
}
|
|
33
|
+
function readPrice(json) {
|
|
34
|
+
const price = json.priceInformation?.defaultPriceInRobux ?? json.price ?? json.priceInRobux ?? null;
|
|
35
|
+
return {
|
|
36
|
+
price: typeof price === "number" ? price : null,
|
|
37
|
+
isForSale: Boolean(json.isForSale ?? json.priceInformation != null),
|
|
38
|
+
name: String(json.name ?? json.displayName ?? ""),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export class OpenCloud {
|
|
42
|
+
apiKey;
|
|
43
|
+
universeId;
|
|
44
|
+
constructor(apiKey, universeId) {
|
|
45
|
+
this.apiKey = apiKey;
|
|
46
|
+
this.universeId = universeId;
|
|
47
|
+
}
|
|
48
|
+
async request(method, path, label, body, contentType) {
|
|
49
|
+
const headers = { "x-api-key": this.apiKey };
|
|
50
|
+
if (contentType) {
|
|
51
|
+
headers["content-type"] = contentType;
|
|
52
|
+
}
|
|
53
|
+
for (let attempt = 0;; attempt++) {
|
|
54
|
+
const response = await fetch(BASE_URL + path, { method, headers, body });
|
|
55
|
+
const text = await response.text();
|
|
56
|
+
if (response.ok) {
|
|
57
|
+
return text ? JSON.parse(text) : null;
|
|
58
|
+
}
|
|
59
|
+
if (!isRetryable(response.status) || attempt >= MAX_RETRIES) {
|
|
60
|
+
throw new OpenCloudError(response.status, text, label);
|
|
61
|
+
}
|
|
62
|
+
await sleep(RETRY_BASE_MS * 2 ** attempt);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
json(method, path, label, payload) {
|
|
66
|
+
return this.request(method, path, label, JSON.stringify(payload), "application/json");
|
|
67
|
+
}
|
|
68
|
+
async getPrice(target) {
|
|
69
|
+
const json = await this.request("GET", `${priceBasePath(this.universeId, target)}/creator`, `read ${target.kind} ${target.id}`);
|
|
70
|
+
return readPrice(json);
|
|
71
|
+
}
|
|
72
|
+
async setPrice(target, price) {
|
|
73
|
+
const form = new FormData();
|
|
74
|
+
form.set("price", String(price));
|
|
75
|
+
await this.request("PATCH", priceBasePath(this.universeId, target), `price ${target.kind} ${target.id}`, form);
|
|
76
|
+
}
|
|
77
|
+
async setName(target, name) {
|
|
78
|
+
const form = new FormData();
|
|
79
|
+
form.set("name", name);
|
|
80
|
+
await this.request("PATCH", priceBasePath(this.universeId, target), `rename ${target.kind} ${target.id}`, form);
|
|
81
|
+
}
|
|
82
|
+
async listItems(kind) {
|
|
83
|
+
const items = [];
|
|
84
|
+
let pageToken = "";
|
|
85
|
+
do {
|
|
86
|
+
const query = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : "";
|
|
87
|
+
const json = await this.request("GET", listPath(this.universeId, kind) + query, `list ${kind}s`);
|
|
88
|
+
for (const item of json.gamePasses ?? json.developerProducts ?? []) {
|
|
89
|
+
const info = readPrice(item);
|
|
90
|
+
items.push({
|
|
91
|
+
kind,
|
|
92
|
+
id: Number(item.gamePassId ?? item.productId ?? item.id),
|
|
93
|
+
name: info.name,
|
|
94
|
+
price: info.price,
|
|
95
|
+
isForSale: Boolean(item.isForSale),
|
|
96
|
+
iconAssetId: Number(item.iconAssetId ?? item.iconImageAssetId) || null,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
pageToken = json.nextPageToken ?? "";
|
|
100
|
+
} while (pageToken);
|
|
101
|
+
return items;
|
|
102
|
+
}
|
|
103
|
+
async listPriceTargets(kind) {
|
|
104
|
+
const targets = [];
|
|
105
|
+
let pageToken = "";
|
|
106
|
+
do {
|
|
107
|
+
const query = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : "";
|
|
108
|
+
const json = await this.request("GET", listPath(this.universeId, kind) + query, `list ${kind}s`);
|
|
109
|
+
const items = json.gamePasses ?? json.developerProducts ?? json.data ?? [];
|
|
110
|
+
for (const item of items) {
|
|
111
|
+
const id = item.gamePassId ?? item.productId ?? item.id;
|
|
112
|
+
if (typeof id === "number") {
|
|
113
|
+
targets.push({ kind, id });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
pageToken = json.nextPageToken ?? "";
|
|
117
|
+
} while (pageToken);
|
|
118
|
+
return targets;
|
|
119
|
+
}
|
|
120
|
+
async setIcon(image, languageCode) {
|
|
121
|
+
const form = new FormData();
|
|
122
|
+
form.set("Files", image, "icon.png");
|
|
123
|
+
const path = `/legacy-game-internationalization/v1/game-icon/games/${this.universeId}/language-codes/${languageCode}`;
|
|
124
|
+
await this.request("POST", path, "set game icon", form);
|
|
125
|
+
}
|
|
126
|
+
async setSourceLanguage(languageCode) {
|
|
127
|
+
const path = `/legacy-game-internationalization/v1/source-language/games/${this.universeId}?languageCode=${encodeURIComponent(languageCode)}`;
|
|
128
|
+
await this.request("PATCH", path, `set source language ${languageCode}`);
|
|
129
|
+
}
|
|
130
|
+
listIcons() {
|
|
131
|
+
const path = `/legacy-game-internationalization/v1/game-icon/games/${this.universeId}`;
|
|
132
|
+
return this.request("GET", path, "list game icons");
|
|
133
|
+
}
|
|
134
|
+
async deleteIcon(languageCode) {
|
|
135
|
+
const path = `/legacy-game-internationalization/v1/game-icon/games/${this.universeId}/language-codes/${languageCode}`;
|
|
136
|
+
await this.request("DELETE", path, `delete ${languageCode} icon`);
|
|
137
|
+
}
|
|
138
|
+
async uploadImage(image, displayName) {
|
|
139
|
+
const universe = await this.getUniverse();
|
|
140
|
+
const owner = /^(users|groups)\/(\d+)$/.exec(String(universe?.user ?? universe?.group ?? ""));
|
|
141
|
+
if (!owner) {
|
|
142
|
+
throw new Error("Could not tell who owns this game");
|
|
143
|
+
}
|
|
144
|
+
const creator = owner[1] === "users" ? { userId: owner[2] } : { groupId: owner[2] };
|
|
145
|
+
const form = new FormData();
|
|
146
|
+
form.set("request", JSON.stringify({ assetType: "Image", displayName, description: "Uploaded by UpdateDay", creationContext: { creator } }));
|
|
147
|
+
form.set("fileContent", image, "image.png");
|
|
148
|
+
let operation = await this.request("POST", "/assets/v1/assets", "upload image", form);
|
|
149
|
+
for (let attempt = 0; !operation?.done && attempt < UPLOAD_POLLS; attempt++) {
|
|
150
|
+
await sleep(UPLOAD_POLL_MS);
|
|
151
|
+
const id = String(operation?.path ?? operation?.operationId ?? "").split("/").pop();
|
|
152
|
+
operation = await this.request("GET", `/assets/v1/operations/${id}`, "check upload");
|
|
153
|
+
}
|
|
154
|
+
const assetId = Number(operation?.response?.assetId);
|
|
155
|
+
if (!operation?.done || !assetId) {
|
|
156
|
+
throw new Error(`Image upload didn't finish: ${JSON.stringify(operation).slice(0, 200)}`);
|
|
157
|
+
}
|
|
158
|
+
return assetId;
|
|
159
|
+
}
|
|
160
|
+
getUniverse() {
|
|
161
|
+
return this.request("GET", `/cloud/v2/universes/${this.universeId}`, "read universe");
|
|
162
|
+
}
|
|
163
|
+
async rootPlaceId() {
|
|
164
|
+
const universe = await this.getUniverse();
|
|
165
|
+
const placeId = Number(String(universe?.rootPlace ?? "").split("/").pop());
|
|
166
|
+
if (!Number.isInteger(placeId) || placeId <= 0) {
|
|
167
|
+
throw new Error("Could not find the game's root place");
|
|
168
|
+
}
|
|
169
|
+
return placeId;
|
|
170
|
+
}
|
|
171
|
+
getPlace(placeId) {
|
|
172
|
+
return this.request("GET", `/cloud/v2/universes/${this.universeId}/places/${placeId}`, "read place");
|
|
173
|
+
}
|
|
174
|
+
async setNameDescription(name, description) {
|
|
175
|
+
const placeId = await this.rootPlaceId();
|
|
176
|
+
const fields = {};
|
|
177
|
+
if (name !== undefined) {
|
|
178
|
+
fields.displayName = name;
|
|
179
|
+
}
|
|
180
|
+
if (description !== undefined) {
|
|
181
|
+
fields.description = description;
|
|
182
|
+
}
|
|
183
|
+
const mask = Object.keys(fields).join(",");
|
|
184
|
+
const path = `/cloud/v2/universes/${this.universeId}/places/${placeId}?updateMask=${mask}`;
|
|
185
|
+
const updated = await this.json("PATCH", path, "set name/description", fields);
|
|
186
|
+
return { name: String(updated?.displayName ?? ""), description: String(updated?.description ?? "") };
|
|
187
|
+
}
|
|
188
|
+
createEvent(input) {
|
|
189
|
+
const path = `/virtual-events/v3/universes/${this.universeId}/game-events`;
|
|
190
|
+
return this.json("POST", path, "create event", input);
|
|
191
|
+
}
|
|
192
|
+
publishMessage(topic, message) {
|
|
193
|
+
const path = `/cloud/v2/universes/${this.universeId}:publishMessage`;
|
|
194
|
+
return this.json("POST", path, "publish message", { topic, message });
|
|
195
|
+
}
|
|
196
|
+
restartServers(bleedOffMinutes) {
|
|
197
|
+
const path = `/cloud/v2/universes/${this.universeId}:restartServers`;
|
|
198
|
+
const payload = bleedOffMinutes > 0
|
|
199
|
+
? { closeAllVersions: false, bleedOffServers: true, bleedOffDurationMinutes: bleedOffMinutes }
|
|
200
|
+
: { closeAllVersions: false, bleedOffServers: false };
|
|
201
|
+
return this.json("POST", path, "restart servers", payload);
|
|
202
|
+
}
|
|
203
|
+
publishPlace(placeId, file) {
|
|
204
|
+
const path = `/universes/v1/${this.universeId}/places/${placeId}/versions?versionType=Published`;
|
|
205
|
+
return this.request("POST", path, "publish place", file, "application/octet-stream");
|
|
206
|
+
}
|
|
207
|
+
getEntry(dataStore, key, scope = "global") {
|
|
208
|
+
const path = `/cloud/v2/universes/${this.universeId}/data-stores/${encodeURIComponent(dataStore)}/scopes/${encodeURIComponent(scope)}/entries/${encodeURIComponent(key)}`;
|
|
209
|
+
return this.request("GET", path, `read entry ${dataStore}/${key}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { OpenCloudError } from "./openCloud.js";
|
|
4
|
+
const USERNAMES_API = "https://users.roblox.com/v1/usernames/users";
|
|
5
|
+
const USERS_API = "https://users.roblox.com/v1/users";
|
|
6
|
+
const HEADSHOT_API = "https://thumbnails.roblox.com/v1/users/avatar-headshot";
|
|
7
|
+
const BACKUP_DIR = "updateday-backups";
|
|
8
|
+
const USER_ID_TOKEN = /\{userId\}/g;
|
|
9
|
+
const SAFE_NAME = /[^\w.-]+/g;
|
|
10
|
+
async function avatar(userId) {
|
|
11
|
+
const response = await fetch(`${HEADSHOT_API}?userIds=${userId}&size=150x150&format=Png`);
|
|
12
|
+
return response.ok ? (await response.json()).data?.[0]?.imageUrl ?? null : null;
|
|
13
|
+
}
|
|
14
|
+
export async function lookupPlayer(query) {
|
|
15
|
+
const text = query.trim().replace(/^@/, "");
|
|
16
|
+
let user = null;
|
|
17
|
+
if (/^\d+$/.test(text)) {
|
|
18
|
+
const response = await fetch(`${USERS_API}/${text}`);
|
|
19
|
+
user = response.ok ? await response.json() : null;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
const response = await fetch(USERNAMES_API, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ usernames: [text], excludeBannedUsers: false }),
|
|
26
|
+
});
|
|
27
|
+
user = response.ok ? (await response.json()).data?.[0] ?? null : null;
|
|
28
|
+
}
|
|
29
|
+
if (!user?.id) {
|
|
30
|
+
throw new Error(`No Roblox player called "${text}"`);
|
|
31
|
+
}
|
|
32
|
+
return { id: user.id, name: user.name, displayName: user.displayName ?? user.name, avatarUrl: await avatar(user.id) };
|
|
33
|
+
}
|
|
34
|
+
export function entryKey(keyFormat, userId) {
|
|
35
|
+
return keyFormat.replace(USER_ID_TOKEN, String(userId));
|
|
36
|
+
}
|
|
37
|
+
function entryPath(cloud, dataStore, key) {
|
|
38
|
+
return `/cloud/v2/universes/${cloud.universeId}/data-stores/${encodeURIComponent(dataStore)}/entries/${encodeURIComponent(key)}`;
|
|
39
|
+
}
|
|
40
|
+
export async function listDataStores(cloud) {
|
|
41
|
+
const names = [];
|
|
42
|
+
let pageToken = "";
|
|
43
|
+
do {
|
|
44
|
+
const query = pageToken ? `?pageToken=${encodeURIComponent(pageToken)}` : "";
|
|
45
|
+
const json = await cloud.request("GET", `/cloud/v2/universes/${cloud.universeId}/data-stores${query}`, "list data stores");
|
|
46
|
+
for (const store of json?.dataStores ?? []) {
|
|
47
|
+
names.push(String(store.id ?? String(store.path).split("/").pop()));
|
|
48
|
+
}
|
|
49
|
+
pageToken = json?.nextPageToken ?? "";
|
|
50
|
+
} while (pageToken);
|
|
51
|
+
return names;
|
|
52
|
+
}
|
|
53
|
+
export async function readEntry(cloud, dataStore, key) {
|
|
54
|
+
try {
|
|
55
|
+
const json = await cloud.request("GET", entryPath(cloud, dataStore, key), "read entry");
|
|
56
|
+
return { value: json.value, etag: json.etag ?? null, revisionId: json.revisionId ?? null, updateTime: json.revisionCreateTime ?? null };
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error instanceof OpenCloudError && error.status === 404) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function backupFolder(baseDir, universeId, dataStore, key) {
|
|
66
|
+
return join(baseDir, BACKUP_DIR, String(universeId), dataStore.replace(SAFE_NAME, "_"), key.replace(SAFE_NAME, "_"));
|
|
67
|
+
}
|
|
68
|
+
export function writeBackup(baseDir, universeId, dataStore, key, entry, reason) {
|
|
69
|
+
const folder = backupFolder(baseDir, universeId, dataStore, key);
|
|
70
|
+
mkdirSync(folder, { recursive: true });
|
|
71
|
+
const savedAt = new Date().toISOString();
|
|
72
|
+
const file = `${savedAt.replace(/[:.]/g, "-")}.json`;
|
|
73
|
+
writeFileSync(join(folder, file), JSON.stringify({ savedAt, reason, dataStore, key, existed: entry !== null, value: entry?.value ?? null }, null, "\t"));
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
export function listBackups(baseDir, universeId, dataStore, key) {
|
|
77
|
+
const folder = backupFolder(baseDir, universeId, dataStore, key);
|
|
78
|
+
if (!existsSync(folder)) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return readdirSync(folder)
|
|
82
|
+
.filter((file) => file.endsWith(".json"))
|
|
83
|
+
.sort()
|
|
84
|
+
.reverse()
|
|
85
|
+
.map((file) => {
|
|
86
|
+
const data = JSON.parse(readFileSync(join(folder, file), "utf8"));
|
|
87
|
+
return { file, savedAt: data.savedAt, reason: data.reason };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export function readBackup(baseDir, universeId, dataStore, key, file) {
|
|
91
|
+
if (!/^[\w-]+\.json$/.test(file)) {
|
|
92
|
+
throw new Error("Bad backup name");
|
|
93
|
+
}
|
|
94
|
+
return JSON.parse(readFileSync(join(backupFolder(baseDir, universeId, dataStore, key), file), "utf8")).value;
|
|
95
|
+
}
|
|
96
|
+
export async function saveEntry(cloud, baseDir, dataStore, key, value, expectedEtag, reason) {
|
|
97
|
+
const current = await readEntry(cloud, dataStore, key);
|
|
98
|
+
if (expectedEtag && current?.etag && current.etag !== expectedEtag) {
|
|
99
|
+
throw new Error("This player's data changed since you loaded it (they may be in game). Reload and try again.");
|
|
100
|
+
}
|
|
101
|
+
writeBackup(baseDir, cloud.universeId, dataStore, key, current, reason);
|
|
102
|
+
const body = { value };
|
|
103
|
+
if (current?.etag) {
|
|
104
|
+
body.etag = current.etag;
|
|
105
|
+
}
|
|
106
|
+
const path = `${entryPath(cloud, dataStore, key)}?allowMissing=true`;
|
|
107
|
+
const json = await cloud.json("PATCH", path, "save entry", body);
|
|
108
|
+
return { value: json.value, etag: json.etag ?? null, revisionId: json.revisionId ?? null, updateTime: json.revisionCreateTime ?? null };
|
|
109
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
import { nextOccurrence } from "./config.js";
|
|
5
|
+
import { checkKey } from "./doctor.js";
|
|
6
|
+
import { fetchCurrentIcon, ICON_SIZE, renderIcon } from "./icons.js";
|
|
7
|
+
import { iconMarks } from "./bundle.js";
|
|
8
|
+
import { moderationStatus } from "./moderation.js";
|
|
9
|
+
import { titleFor } from "./runner.js";
|
|
10
|
+
import { buildSchedule } from "./schedule.js";
|
|
11
|
+
const EVENT_LEAD_MS = 15 * 60 * 1000;
|
|
12
|
+
function when(date) {
|
|
13
|
+
return date.toUTCString().replace(" GMT", " UTC");
|
|
14
|
+
}
|
|
15
|
+
function timingChecks({ config, now }) {
|
|
16
|
+
const checks = [];
|
|
17
|
+
const updateAt = new Date(config.updateAt);
|
|
18
|
+
if (updateAt <= now) {
|
|
19
|
+
checks.push({ level: "warn", title: "The update time has passed", detail: `It was ${when(updateAt)}. Steps that are still due run right away.` });
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
checks.push({ level: "ok", title: `Update at ${when(updateAt)}` });
|
|
23
|
+
}
|
|
24
|
+
const steps = buildSchedule(config).filter((step) => step.at.getTime() > 0);
|
|
25
|
+
const missed = steps.filter((step) => step.at <= now && step.action.type === "icon");
|
|
26
|
+
if (missed.length > 1 && updateAt > now) {
|
|
27
|
+
checks.push({ level: "info", title: "The countdown starts part-way", detail: `${missed.length - 1} earlier stage${missed.length === 2 ? " is" : "s are"} already past and will be skipped.` });
|
|
28
|
+
}
|
|
29
|
+
const next = nextOccurrence(config);
|
|
30
|
+
if (config.repeat) {
|
|
31
|
+
checks.push({ level: "info", title: `Repeats every ${config.repeat.every === "2weeks" ? "2 weeks" : config.repeat.every}`, detail: next ? `After this one: ${when(next)}` : "This is the last one." });
|
|
32
|
+
}
|
|
33
|
+
return checks;
|
|
34
|
+
}
|
|
35
|
+
function fileChecks({ config }) {
|
|
36
|
+
const checks = [];
|
|
37
|
+
const files = [
|
|
38
|
+
[config.countdown?.newIcon, "The new game icon"],
|
|
39
|
+
[config.publish ? resolve(config.baseDir, config.publish.file) : undefined, "The place file to publish"],
|
|
40
|
+
];
|
|
41
|
+
for (const [path, what] of files) {
|
|
42
|
+
if (path && !existsSync(path)) {
|
|
43
|
+
checks.push({ level: "bad", title: `${what} is missing`, detail: path });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const artDir = config.countdown?.overlayDir;
|
|
47
|
+
if (artDir && !existsSync(artDir)) {
|
|
48
|
+
checks.push({ level: "warn", title: "No hourglass art uploaded yet", detail: "The built-in hourglass is used until you add your own." });
|
|
49
|
+
}
|
|
50
|
+
return checks;
|
|
51
|
+
}
|
|
52
|
+
async function baseIcon(config) {
|
|
53
|
+
if (existsSync(config.snapshotPath)) {
|
|
54
|
+
return readFileSync(config.snapshotPath);
|
|
55
|
+
}
|
|
56
|
+
return sharp(await fetchCurrentIcon(config.universeId)).resize(ICON_SIZE, ICON_SIZE).png().toBuffer();
|
|
57
|
+
}
|
|
58
|
+
async function iconChecks({ config }) {
|
|
59
|
+
const countdown = config.countdown;
|
|
60
|
+
if (!countdown) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
let base;
|
|
64
|
+
try {
|
|
65
|
+
base = await baseIcon(config);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
return [{ level: "bad", title: "Couldn't read the game icon", detail: error instanceof Error ? error.message : String(error) }];
|
|
69
|
+
}
|
|
70
|
+
const marks = iconMarks(countdown);
|
|
71
|
+
const broken = [];
|
|
72
|
+
for (const mark of marks) {
|
|
73
|
+
try {
|
|
74
|
+
await renderIcon(base, mark, countdown);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
broken.push(`${mark}: ${error instanceof Error ? error.message : error}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (broken.length) {
|
|
81
|
+
return [{ level: "bad", title: `${broken.length} countdown icon${broken.length === 1 ? "" : "s"} won't render`, detail: broken.join("; ") }];
|
|
82
|
+
}
|
|
83
|
+
const how = countdown.iconMethod === "languageSwap" ? "uploaded automatically" : "made for you to upload";
|
|
84
|
+
return [{ level: "ok", title: `${marks.length} countdown icons render`, detail: `They'll be ${how}.` }];
|
|
85
|
+
}
|
|
86
|
+
async function keyChecks({ config, cloud }) {
|
|
87
|
+
if (!cloud) {
|
|
88
|
+
return [{ level: "bad", title: "No API key", detail: `Paste one in UpdateDay or set ${config.apiKeyEnv}.` }];
|
|
89
|
+
}
|
|
90
|
+
const probes = await checkKey(cloud, config);
|
|
91
|
+
const blocking = probes.filter((probe) => probe.used && (probe.status === "missing" || probe.status === "error"));
|
|
92
|
+
if (blocking.length) {
|
|
93
|
+
return [{ level: "bad", title: `The API key is missing ${blocking.length} permission${blocking.length === 1 ? "" : "s"}`, detail: blocking.map((probe) => `${probe.feature} (${probe.permission})`).join(", ") }];
|
|
94
|
+
}
|
|
95
|
+
return [{ level: "ok", title: "The API key has every permission the plan needs" }];
|
|
96
|
+
}
|
|
97
|
+
async function saleChecks({ config, cloud, now }) {
|
|
98
|
+
const sale = config.sale;
|
|
99
|
+
if (!sale) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const checks = [];
|
|
103
|
+
const endsAt = sale.endsAt ? Date.parse(sale.endsAt) : (sale.startsAt ? Date.parse(sale.startsAt) : Date.parse(config.updateAt)) + sale.durationHours * 36e5;
|
|
104
|
+
if (endsAt <= now.getTime()) {
|
|
105
|
+
checks.push({ level: "warn", title: "The sale's end time has already passed", detail: "It would start and end straight away." });
|
|
106
|
+
}
|
|
107
|
+
if (!cloud) {
|
|
108
|
+
return checks;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const items = [...(await cloud.listItems("gamePass")), ...(await cloud.listItems("developerProduct"))];
|
|
112
|
+
const picked = (kind, setting) => {
|
|
113
|
+
const sellable = items.filter((item) => item.kind === kind && item.isForSale && item.price !== null);
|
|
114
|
+
if (setting === "all") {
|
|
115
|
+
return { count: sellable.length, missing: [] };
|
|
116
|
+
}
|
|
117
|
+
const ids = new Set(sellable.map((item) => item.id));
|
|
118
|
+
return { count: setting.filter((id) => ids.has(id)).length, missing: setting.filter((id) => !ids.has(id)) };
|
|
119
|
+
};
|
|
120
|
+
const passes = picked("gamePass", sale.gamePasses);
|
|
121
|
+
const products = picked("developerProduct", sale.developerProducts);
|
|
122
|
+
const total = passes.count + products.count;
|
|
123
|
+
const missing = [...passes.missing, ...products.missing];
|
|
124
|
+
if (total === 0) {
|
|
125
|
+
checks.push({ level: "warn", title: "Nothing would go on sale", detail: "No picked pass or product is for sale right now." });
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
checks.push({ level: "ok", title: `${total} item${total === 1 ? "" : "s"} go on sale at ${sale.percentOff}% off` });
|
|
129
|
+
}
|
|
130
|
+
if (missing.length) {
|
|
131
|
+
checks.push({ level: "warn", title: `${missing.length} picked item${missing.length === 1 ? " isn't" : "s aren't"} for sale any more`, detail: `They'll be skipped: ${missing.join(", ")}` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
checks.push({ level: "warn", title: "Couldn't list the passes and products", detail: error instanceof Error ? error.message : String(error) });
|
|
136
|
+
}
|
|
137
|
+
return checks;
|
|
138
|
+
}
|
|
139
|
+
function eventChecks({ config, now }) {
|
|
140
|
+
if (!config.event) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
if (Date.parse(config.updateAt) - now.getTime() < EVENT_LEAD_MS) {
|
|
144
|
+
return [{ level: "warn", title: "The event will start a little late", detail: "Roblox events need 15 minutes' notice, so it starts about 20 minutes from now." }];
|
|
145
|
+
}
|
|
146
|
+
return [{ level: "ok", title: `Event "${config.event.title}" will be created` }];
|
|
147
|
+
}
|
|
148
|
+
function titleChecks({ config }) {
|
|
149
|
+
if (!config.title) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
const example = titleFor(config.title.format, config.name, "6H");
|
|
153
|
+
return [{ level: "info", title: "Title countdown is on", detail: `For example "${example}", then "${titleFor(config.title.nowFormat, config.name, "LIVE")}". The real title is saved first and put back.` }];
|
|
154
|
+
}
|
|
155
|
+
async function moderationChecks({ config, cloud }) {
|
|
156
|
+
if (!config.countdown || !cloud) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const stages = await moderationStatus(config, cloud, await baseIcon(config));
|
|
161
|
+
const rejected = stages.filter((stage) => stage.state === "Rejected").map((stage) => stage.mark);
|
|
162
|
+
const reviewing = stages.filter((stage) => stage.state === "Reviewing").length;
|
|
163
|
+
const unchecked = stages.filter((stage) => stage.state === "unchecked").length;
|
|
164
|
+
if (rejected.length) {
|
|
165
|
+
return [{ level: "bad", title: `Roblox moderation rejected ${rejected.length} icon${rejected.length === 1 ? "" : "s"}`, detail: `Change the look of: ${rejected.join(", ")}` }];
|
|
166
|
+
}
|
|
167
|
+
if (unchecked === stages.length) {
|
|
168
|
+
return [{ level: "info", title: "Icons not checked with Roblox moderation yet", detail: "Optional: Countdown → Stages → Check with moderation." }];
|
|
169
|
+
}
|
|
170
|
+
if (reviewing || unchecked) {
|
|
171
|
+
return [{ level: "info", title: "Moderation check in progress", detail: `${reviewing} in review, ${unchecked} not checked since the last change.` }];
|
|
172
|
+
}
|
|
173
|
+
return [{ level: "ok", title: `Roblox moderation approved all ${stages.length} icons` }];
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
return [{ level: "warn", title: "Couldn't read the moderation status", detail: error instanceof Error ? error.message : String(error) }];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function notifyChecks({ config, webhook }) {
|
|
180
|
+
if (!config.notify) {
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
if (!webhook) {
|
|
184
|
+
return [{ level: "warn", title: "Discord alerts are on, but there's no webhook", detail: `Paste one in UpdateDay or set ${config.notify.discordWebhookEnv}.` }];
|
|
185
|
+
}
|
|
186
|
+
return [{ level: "ok", title: "Discord alerts are ready" }];
|
|
187
|
+
}
|
|
188
|
+
export async function preflight(config, cloud, webhook, now = new Date()) {
|
|
189
|
+
const inputs = { config, cloud, webhook, now };
|
|
190
|
+
const checks = [
|
|
191
|
+
...timingChecks(inputs),
|
|
192
|
+
...(await keyChecks(inputs)),
|
|
193
|
+
...fileChecks(inputs),
|
|
194
|
+
...(await iconChecks(inputs)),
|
|
195
|
+
...(await saleChecks(inputs)),
|
|
196
|
+
...eventChecks(inputs),
|
|
197
|
+
...titleChecks(inputs),
|
|
198
|
+
...(await moderationChecks(inputs)),
|
|
199
|
+
...notifyChecks(inputs),
|
|
200
|
+
];
|
|
201
|
+
return { game: config.name, universeId: config.universeId, ready: !checks.some((check) => check.level === "bad"), checks };
|
|
202
|
+
}
|