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/state.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
let logSink = null;
|
|
3
|
+
export function setLogSink(sink) {
|
|
4
|
+
logSink = sink;
|
|
5
|
+
}
|
|
6
|
+
export function loadState(path, updateAt) {
|
|
7
|
+
if (existsSync(path)) {
|
|
8
|
+
const state = JSON.parse(readFileSync(path, "utf8"));
|
|
9
|
+
if (state.updateAt === updateAt) {
|
|
10
|
+
return state;
|
|
11
|
+
}
|
|
12
|
+
const swapOpen = state.swap?.swapped && !state.swap.finished;
|
|
13
|
+
if (swapOpen || state.prices.some((price) => !price.restored || (price.taggedName && !price.nameRestored))) {
|
|
14
|
+
throw new Error(`State file ${path} is from a different update and still has unrestored sale prices. Run "restore" first.`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { updateAt, done: {}, prices: [], log: [] };
|
|
18
|
+
}
|
|
19
|
+
export function saveState(path, state) {
|
|
20
|
+
const temp = `${path}.tmp`;
|
|
21
|
+
writeFileSync(temp, JSON.stringify(state, null, "\t"));
|
|
22
|
+
renameSync(temp, path);
|
|
23
|
+
}
|
|
24
|
+
export function logLine(state, message) {
|
|
25
|
+
const line = `${new Date().toISOString()} ${message}`;
|
|
26
|
+
state.log.push(line);
|
|
27
|
+
console.log(line);
|
|
28
|
+
logSink?.(message);
|
|
29
|
+
}
|
package/dist/swap.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { OpenCloudError } from "./openCloud.js";
|
|
4
|
+
import { logLine, saveState } from "./state.js";
|
|
5
|
+
const PUBLIC_I18N = "https://gameinternationalization.roblox.com/v1";
|
|
6
|
+
const TEMP_PREFERENCE = ["th", "vi", "pl", "id", "tr", "it", "ko", "ja", "fr", "de", "pt", "ru", "es", "zh-hant", "zh-hans"];
|
|
7
|
+
const UPLOAD_GAP_MS = 250;
|
|
8
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
9
|
+
const APPROVAL_POLL_MS = 5000;
|
|
10
|
+
const APPROVAL_MAX_MS = 150 * 1000;
|
|
11
|
+
const SETTLE_POLLS = 20;
|
|
12
|
+
const SETTLE_POLL_MS = 3000;
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
async function publicJson(path) {
|
|
17
|
+
const response = await fetch(PUBLIC_I18N + path);
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
throw new Error(`Couldn't read ${path} (${response.status})`);
|
|
20
|
+
}
|
|
21
|
+
return response.json();
|
|
22
|
+
}
|
|
23
|
+
export async function sourceLanguage(universeId) {
|
|
24
|
+
return String((await publicJson(`/source-language/games/${universeId}`)).languageCode);
|
|
25
|
+
}
|
|
26
|
+
export async function supportedLanguages(universeId) {
|
|
27
|
+
const json = await publicJson(`/supported-languages/games/${universeId}`);
|
|
28
|
+
return (json?.data ?? []).map((item) => String(item.languageCode)).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
export function pickTemp(source, supported, preferred) {
|
|
31
|
+
if (preferred) {
|
|
32
|
+
if (preferred === source) {
|
|
33
|
+
throw new Error(`swapLanguage can't be the game's source language (${source})`);
|
|
34
|
+
}
|
|
35
|
+
return preferred;
|
|
36
|
+
}
|
|
37
|
+
const candidates = supported.filter((code) => code !== source);
|
|
38
|
+
const ranked = TEMP_PREFERENCE.find((code) => candidates.includes(code));
|
|
39
|
+
if (!ranked && candidates.length === 0) {
|
|
40
|
+
throw new Error("The game supports only one language; add one in Creator Hub (Localization) so the countdown can use the language swap");
|
|
41
|
+
}
|
|
42
|
+
return ranked ?? candidates[candidates.length - 1];
|
|
43
|
+
}
|
|
44
|
+
async function waitForSource(universeId, expected) {
|
|
45
|
+
for (let attempt = 0; attempt < SETTLE_POLLS; attempt++) {
|
|
46
|
+
if ((await sourceLanguage(universeId).catch(() => "")) === expected) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
await sleep(SETTLE_POLL_MS);
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
async function saveExistingIcons(cloud, source, folder) {
|
|
54
|
+
const saved = {};
|
|
55
|
+
const list = await cloud.listIcons();
|
|
56
|
+
for (const icon of list?.data ?? []) {
|
|
57
|
+
if (icon.languageCode === source || !icon.imageUrl) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const response = await fetch(icon.imageUrl);
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
throw new Error(`Couldn't save the existing ${icon.languageCode} icon before the swap`);
|
|
63
|
+
}
|
|
64
|
+
mkdirSync(folder, { recursive: true });
|
|
65
|
+
const file = join(folder, `${icon.languageCode}.png`);
|
|
66
|
+
writeFileSync(file, Buffer.from(await response.arrayBuffer()));
|
|
67
|
+
saved[icon.languageCode] = file;
|
|
68
|
+
}
|
|
69
|
+
return saved;
|
|
70
|
+
}
|
|
71
|
+
export async function startSwap(cloud, state, statePath, preferredTemp) {
|
|
72
|
+
if (state.swap?.swapped) {
|
|
73
|
+
return state.swap;
|
|
74
|
+
}
|
|
75
|
+
const originalSource = await sourceLanguage(cloud.universeId);
|
|
76
|
+
const supported = await supportedLanguages(cloud.universeId);
|
|
77
|
+
const temp = pickTemp(originalSource, supported, preferredTemp);
|
|
78
|
+
const languages = [originalSource, ...supported.filter((code) => code !== originalSource && code !== temp)];
|
|
79
|
+
const folder = join(dirname(statePath), "updateday-saved-icons", String(cloud.universeId));
|
|
80
|
+
const savedIcons = await saveExistingIcons(cloud, originalSource, folder);
|
|
81
|
+
state.swap = { originalSource, temp, languages, savedIcons, swapped: false, removed: [], restoredSaved: [], finished: false };
|
|
82
|
+
saveState(statePath, state);
|
|
83
|
+
await cloud.setSourceLanguage(temp);
|
|
84
|
+
state.swap.swapped = true;
|
|
85
|
+
saveState(statePath, state);
|
|
86
|
+
logLine(state, ` source language ${originalSource} -> ${temp}; icons go to ${languages.length} languages`);
|
|
87
|
+
if (!(await waitForSource(cloud.universeId, temp))) {
|
|
88
|
+
logLine(state, ` warning: Roblox still reports the old source language`);
|
|
89
|
+
}
|
|
90
|
+
return state.swap;
|
|
91
|
+
}
|
|
92
|
+
async function uploadOne(cloud, code, imageFor) {
|
|
93
|
+
try {
|
|
94
|
+
await cloud.setIcon(await imageFor(code), code);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function waitForApproval(cloud, codes) {
|
|
102
|
+
const started = Date.now();
|
|
103
|
+
let states = {};
|
|
104
|
+
while (Date.now() - started < APPROVAL_MAX_MS) {
|
|
105
|
+
states = {};
|
|
106
|
+
for (const icon of (await cloud.listIcons().catch(() => null))?.data ?? []) {
|
|
107
|
+
states[icon.languageCode] = icon.state;
|
|
108
|
+
}
|
|
109
|
+
if (codes.every((code) => states[code] === "Approved" || states[code] === "Rejected")) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
await sleep(APPROVAL_POLL_MS);
|
|
113
|
+
}
|
|
114
|
+
return states;
|
|
115
|
+
}
|
|
116
|
+
export async function uploadAll(cloud, state, imageFor) {
|
|
117
|
+
const swap = state.swap;
|
|
118
|
+
const started = Date.now();
|
|
119
|
+
const failed = [];
|
|
120
|
+
const queue = [...swap.languages];
|
|
121
|
+
const worker = async () => {
|
|
122
|
+
for (let code = queue.shift(); code; code = queue.shift()) {
|
|
123
|
+
if (!(await uploadOne(cloud, code, imageFor))) {
|
|
124
|
+
failed.push(code);
|
|
125
|
+
}
|
|
126
|
+
await sleep(UPLOAD_GAP_MS);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
await Promise.all(Array.from({ length: UPLOAD_CONCURRENCY }, worker));
|
|
130
|
+
if (failed.includes(swap.originalSource) && !(await uploadOne(cloud, swap.originalSource, imageFor))) {
|
|
131
|
+
throw new Error(`Couldn't set the ${swap.originalSource} countdown icon`);
|
|
132
|
+
}
|
|
133
|
+
if (failed.length > 0) {
|
|
134
|
+
logLine(state, ` icon not set for: ${failed.filter((code) => code !== swap.originalSource).join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
const uploaded = swap.languages.filter((code) => !failed.includes(code) || code === swap.originalSource);
|
|
137
|
+
const states = await waitForApproval(cloud, uploaded);
|
|
138
|
+
const seconds = Math.round((Date.now() - started) / 1000);
|
|
139
|
+
const rejected = uploaded.filter((code) => states[code] === "Rejected");
|
|
140
|
+
const pending = uploaded.filter((code) => states[code] !== "Approved" && states[code] !== "Rejected");
|
|
141
|
+
if (rejected.length > 0) {
|
|
142
|
+
logLine(state, ` Roblox REJECTED the icon for: ${rejected.join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
if (pending.length > 0) {
|
|
145
|
+
logLine(state, ` still in review after ${seconds}s for: ${pending.join(", ")}; raise uploadLeadSeconds`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
logLine(state, ` icons approved ${seconds}s after upload started`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function deleteIcon(cloud, code) {
|
|
152
|
+
try {
|
|
153
|
+
await cloud.deleteIcon(code);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (!(error instanceof OpenCloudError && error.status === 404)) {
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
export async function finishSwap(cloud, state, statePath) {
|
|
162
|
+
const swap = state.swap;
|
|
163
|
+
if (!swap || swap.finished) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (swap.swapped) {
|
|
167
|
+
for (const code of swap.languages) {
|
|
168
|
+
if (swap.removed.includes(code)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
await deleteIcon(cloud, code);
|
|
172
|
+
swap.removed.push(code);
|
|
173
|
+
saveState(statePath, state);
|
|
174
|
+
await sleep(UPLOAD_GAP_MS);
|
|
175
|
+
}
|
|
176
|
+
for (const [code, file] of Object.entries(swap.savedIcons)) {
|
|
177
|
+
if (swap.restoredSaved.includes(code) || !existsSync(file)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
await cloud.setIcon(new Blob([new Uint8Array(readFileSync(file))], { type: "image/png" }), code);
|
|
181
|
+
swap.restoredSaved.push(code);
|
|
182
|
+
saveState(statePath, state);
|
|
183
|
+
}
|
|
184
|
+
await cloud.setSourceLanguage(swap.originalSource);
|
|
185
|
+
if (!(await waitForSource(cloud.universeId, swap.originalSource))) {
|
|
186
|
+
throw new Error(`Source language didn't switch back to ${swap.originalSource}; run restore again`);
|
|
187
|
+
}
|
|
188
|
+
const leftover = ((await cloud.listIcons())?.data ?? []).filter((icon) => icon.languageCode !== swap.originalSource && swap.languages.includes(icon.languageCode) && !swap.savedIcons[icon.languageCode]);
|
|
189
|
+
if (leftover.length > 0) {
|
|
190
|
+
throw new Error(`Countdown icons still set for: ${leftover.map((icon) => icon.languageCode).join(", ")}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
swap.finished = true;
|
|
194
|
+
saveState(statePath, state);
|
|
195
|
+
logLine(state, ` source language back to ${swap.originalSource}; countdown icons removed`);
|
|
196
|
+
}
|
package/dist/zip.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const LOCAL_HEADER = 0x04034b50;
|
|
2
|
+
const CENTRAL_HEADER = 0x02014b50;
|
|
3
|
+
const END_RECORD = 0x06054b50;
|
|
4
|
+
const VERSION = 20;
|
|
5
|
+
const UTF8_FLAG = 0x0800;
|
|
6
|
+
const DOS_EPOCH_YEAR = 1980;
|
|
7
|
+
const CRC_TABLE = (() => {
|
|
8
|
+
const table = new Uint32Array(256);
|
|
9
|
+
for (let n = 0; n < 256; n++) {
|
|
10
|
+
let c = n;
|
|
11
|
+
for (let k = 0; k < 8; k++) {
|
|
12
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
}
|
|
14
|
+
table[n] = c >>> 0;
|
|
15
|
+
}
|
|
16
|
+
return table;
|
|
17
|
+
})();
|
|
18
|
+
export function crc32(data) {
|
|
19
|
+
let crc = 0xffffffff;
|
|
20
|
+
for (const byte of data) {
|
|
21
|
+
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
22
|
+
}
|
|
23
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
24
|
+
}
|
|
25
|
+
function dosTime(date) {
|
|
26
|
+
const time = (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2);
|
|
27
|
+
const day = ((date.getFullYear() - DOS_EPOCH_YEAR) << 9) | ((date.getMonth() + 1) << 5) | date.getDate();
|
|
28
|
+
return { time, day };
|
|
29
|
+
}
|
|
30
|
+
function localHeader(name, entry, crc, stamp) {
|
|
31
|
+
const header = Buffer.alloc(30);
|
|
32
|
+
header.writeUInt32LE(LOCAL_HEADER, 0);
|
|
33
|
+
header.writeUInt16LE(VERSION, 4);
|
|
34
|
+
header.writeUInt16LE(UTF8_FLAG, 6);
|
|
35
|
+
header.writeUInt16LE(0, 8);
|
|
36
|
+
header.writeUInt16LE(stamp.time, 10);
|
|
37
|
+
header.writeUInt16LE(stamp.day, 12);
|
|
38
|
+
header.writeUInt32LE(crc, 14);
|
|
39
|
+
header.writeUInt32LE(entry.data.length, 18);
|
|
40
|
+
header.writeUInt32LE(entry.data.length, 22);
|
|
41
|
+
header.writeUInt16LE(name.length, 26);
|
|
42
|
+
header.writeUInt16LE(0, 28);
|
|
43
|
+
return header;
|
|
44
|
+
}
|
|
45
|
+
function centralHeader(name, entry, crc, stamp, offset) {
|
|
46
|
+
const header = Buffer.alloc(46);
|
|
47
|
+
header.writeUInt32LE(CENTRAL_HEADER, 0);
|
|
48
|
+
header.writeUInt16LE(VERSION, 4);
|
|
49
|
+
header.writeUInt16LE(VERSION, 6);
|
|
50
|
+
header.writeUInt16LE(UTF8_FLAG, 8);
|
|
51
|
+
header.writeUInt16LE(0, 10);
|
|
52
|
+
header.writeUInt16LE(stamp.time, 12);
|
|
53
|
+
header.writeUInt16LE(stamp.day, 14);
|
|
54
|
+
header.writeUInt32LE(crc, 16);
|
|
55
|
+
header.writeUInt32LE(entry.data.length, 20);
|
|
56
|
+
header.writeUInt32LE(entry.data.length, 24);
|
|
57
|
+
header.writeUInt16LE(name.length, 28);
|
|
58
|
+
header.writeUInt32LE(offset, 42);
|
|
59
|
+
return header;
|
|
60
|
+
}
|
|
61
|
+
export function zip(entries, date = new Date()) {
|
|
62
|
+
const stamp = dosTime(date);
|
|
63
|
+
const parts = [];
|
|
64
|
+
const central = [];
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const name = Buffer.from(entry.name, "utf8");
|
|
68
|
+
const crc = crc32(entry.data);
|
|
69
|
+
const local = localHeader(name, entry, crc, stamp);
|
|
70
|
+
parts.push(local, name, entry.data);
|
|
71
|
+
central.push(centralHeader(name, entry, crc, stamp, offset), name);
|
|
72
|
+
offset += local.length + name.length + entry.data.length;
|
|
73
|
+
}
|
|
74
|
+
const directory = Buffer.concat(central);
|
|
75
|
+
const end = Buffer.alloc(22);
|
|
76
|
+
end.writeUInt32LE(END_RECORD, 0);
|
|
77
|
+
end.writeUInt16LE(entries.length, 8);
|
|
78
|
+
end.writeUInt16LE(entries.length, 10);
|
|
79
|
+
end.writeUInt32LE(directory.length, 12);
|
|
80
|
+
end.writeUInt32LE(offset, 16);
|
|
81
|
+
return Buffer.concat([...parts, directory, end]);
|
|
82
|
+
}
|
package/docs/AGENTS.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# UpdateDay for AI agents
|
|
2
|
+
|
|
3
|
+
This page is written for AI agents (and the people setting them up). UpdateDay runs a Roblox game's update day:
|
|
4
|
+
countdown icons and titles, a sale, an event, in-game warnings and server restarts. Its MCP server lets you read
|
|
5
|
+
and change the plan, check it, preview icons and run it, all with the user's own API key.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Run it from the folder that holds the plan (`updateday.json`):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx updateday mcp updateday.json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Claude Code:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
claude mcp add updateday -e UPDATEDAY_API_KEY=your-key -- npx updateday mcp /path/to/updateday.json
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Claude Desktop, Cursor and other clients (`mcpServers` in their config file):
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"updateday": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["updateday", "mcp", "/path/to/updateday.json"],
|
|
29
|
+
"env": {
|
|
30
|
+
"UPDATEDAY_API_KEY": "the Open Cloud API key",
|
|
31
|
+
"UPDATEDAY_DISCORD_WEBHOOK": "optional Discord webhook link for alerts"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If `updateday.json` doesn't exist yet, create it with at least `universeId` and `updateAt` (see the example below).
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | What it does | Changes the live game? |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `get_plan` | The raw plan file plus a summary of every game: update time, countdown, sale, event, alerts | No |
|
|
45
|
+
| `change_plan` | Changes the plan with a JSON merge patch. The result is checked before it's saved | No (the plan file only) |
|
|
46
|
+
| `get_schedule` | Every step of the update day in order, with its time and whether it already ran | No |
|
|
47
|
+
| `check_plan` | Pre-flight: key permissions, times, files, icons, sale items, moderation, alerts | No |
|
|
48
|
+
| `preview_icon` | Renders one countdown icon as an image (stage: `24h`, `1h`, `5m`, `now`, `final` …) | No |
|
|
49
|
+
| `list_sale_items` | The game's passes and developer products with prices | No |
|
|
50
|
+
| `get_history` | What UpdateDay did and when, newest first | No |
|
|
51
|
+
| `run_due_steps` | Runs every step that is due now. Defaults to a dry run | **Yes**, with `dry_run: false` and `confirm: true` |
|
|
52
|
+
| `restore_everything` | Undo: every price, name, title and icon back to normal | **Yes**, needs `confirm: true` |
|
|
53
|
+
|
|
54
|
+
Every tool takes an optional `game` (name or universe ID). Leave it out to cover every game in the plan.
|
|
55
|
+
|
|
56
|
+
## How to work with it
|
|
57
|
+
|
|
58
|
+
1. **Read first.** Call `get_plan`, then `check_plan`. Lines with level `bad` must be fixed. `warn` is worth telling
|
|
59
|
+
the user about; `info` is just information.
|
|
60
|
+
2. **Change the plan with small patches.** `change_plan` takes an [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396)
|
|
61
|
+
merge patch: objects merge, arrays and plain values replace, and `null` removes a key.
|
|
62
|
+
3. **Show before you act.** Use `preview_icon` and `get_schedule` to show the user what will happen, and confirm the
|
|
63
|
+
update time *with its time zone* before saving it.
|
|
64
|
+
4. **Ask before anything live.** Only call `run_due_steps` with `dry_run: false` or `restore_everything` once the user
|
|
65
|
+
has said yes in this conversation. Pass `confirm: true` only then.
|
|
66
|
+
5. **Times are ISO 8601 with a time zone**, e.g. `2026-11-07T18:00:00Z`. Turn "Saturday 6pm my time" into UTC and say
|
|
67
|
+
which time you used.
|
|
68
|
+
|
|
69
|
+
The plan is a file, so changes made through MCP also show up in the setup page (`npx updateday ui`), and the other
|
|
70
|
+
way round.
|
|
71
|
+
|
|
72
|
+
## Plan examples
|
|
73
|
+
|
|
74
|
+
A minimal plan:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{ "universeId": 1234567890, "name": "My Game", "updateAt": "2026-11-07T18:00:00Z" }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Patches for common requests:
|
|
81
|
+
|
|
82
|
+
```jsonc
|
|
83
|
+
// "Move the update to Saturday 6 PM UTC"
|
|
84
|
+
{ "updateAt": "2026-11-07T18:00:00Z" }
|
|
85
|
+
|
|
86
|
+
// "30% off everything for 48 hours, with a tag on the names"
|
|
87
|
+
{ "sale": { "percentOff": 30, "durationHours": 48, "gamePasses": "all", "developerProducts": "all", "nameTag": "[{percent}% OFF]" } }
|
|
88
|
+
|
|
89
|
+
// "Only put game passes 111 and 222 on sale"
|
|
90
|
+
{ "sale": { "gamePasses": [111, 222], "developerProducts": [] } }
|
|
91
|
+
|
|
92
|
+
// "Countdown in the title too, and call it Update 5 afterwards"
|
|
93
|
+
{ "title": { "afterFormat": "{name} [UPDATE 5]" } }
|
|
94
|
+
|
|
95
|
+
// "Pixel hourglass, a bit bigger, and say LIVE! at NOW"
|
|
96
|
+
{ "countdown": { "style": "pixel", "hourglassSize": 0.74, "labels": { "now": "LIVE!" } } }
|
|
97
|
+
|
|
98
|
+
// "Skip the 5 and 4 hour stages"
|
|
99
|
+
{ "countdown": { "skip": ["5h", "4h"] } }
|
|
100
|
+
|
|
101
|
+
// "Warn players 10, 5 and 1 minutes before, and restart old servers"
|
|
102
|
+
{ "broadcast": { "topic": "UpdateDay", "marks": ["10m", "5m", "1m"] }, "restartServers": { "bleedOffMinutes": 5 } }
|
|
103
|
+
|
|
104
|
+
// "Do this every week"
|
|
105
|
+
{ "repeat": { "every": "week" } }
|
|
106
|
+
|
|
107
|
+
// "Turn the sale off"
|
|
108
|
+
{ "sale": null }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Every setting is described in the [guide](GUIDE.md).
|
|
112
|
+
|
|
113
|
+
## Things to know
|
|
114
|
+
|
|
115
|
+
- **The main game icon can't be set with an API key.** With `"iconMethod": "reminder"` (the default) the user uploads
|
|
116
|
+
each finished icon themselves when reminded. With `"languageSwap"` it's automatic. Explain this if the user expects
|
|
117
|
+
the icon to change by itself.
|
|
118
|
+
- **Sales and titles are always undone.** Originals are saved before anything changes. `restore_everything` puts them
|
|
119
|
+
back at any time, and running it twice is safe.
|
|
120
|
+
- **Nothing runs on its own from MCP.** `run_due_steps` does one check, like a single tick. To run a whole update day,
|
|
121
|
+
the user starts it in the setup page, with `npx updateday watch`, or with GitHub Actions.
|
|
122
|
+
- **Keys never go in the plan file.** They come from environment variables (`UPDATEDAY_API_KEY`, or the variable a
|
|
123
|
+
game names in `apiKeyEnv`).
|
package/docs/COVERAGE.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Open Cloud coverage
|
|
2
|
+
|
|
3
|
+
Checked 2026-10-28 against Roblox's published OpenAPI spec
|
|
4
|
+
(`Roblox/creator-docs` → `content/en-us/reference/cloud/openapi.json`).
|
|
5
|
+
|
|
6
|
+
Everything UpdateDay does uses an **Open Cloud API key** (`x-api-key`). No cookie,
|
|
7
|
+
no Connect/OAuth app and no server of ours.
|
|
8
|
+
|
|
9
|
+
| Feature | Endpoint | Scope | Stability |
|
|
10
|
+
|---|---|---|---|
|
|
11
|
+
| Game icon (countdown) | `POST /legacy-game-internationalization/v1/game-icon/games/{universeId}/language-codes/{lang}` (multipart `Files`) | `legacy-universe:manage` | Experimental |
|
|
12
|
+
| Game page thumbnails | `POST .../game-thumbnails/games/{universeId}/language-codes/{lang}/image`, `.../images/order`, `DELETE .../images/{imageId}` | `legacy-universe:manage` | Experimental |
|
|
13
|
+
| Game name / description | `PATCH /legacy-game-internationalization/v1/name-description/games/{universeId}` | `legacy-universe:manage` | Experimental |
|
|
14
|
+
| Name / description (alt) | `PATCH /cloud/v2/universes/{u}/places/{rootPlace}?updateMask=displayName,description` | `universe.place:write` | Stable |
|
|
15
|
+
| Game pass sale | `PATCH /game-passes/v1/universes/{u}/game-passes/{id}` (multipart `price`) | `game-pass:read`, `game-pass:write` | Beta |
|
|
16
|
+
| Dev product sale | `PATCH /developer-products/v2/universes/{u}/developer-products/{id}` (multipart `price`) | `developer-product:read`, `developer-product:write` | Beta |
|
|
17
|
+
| Roblox event | `POST /virtual-events/v3/universes/{u}/game-events` | `universe.event:write` | Experimental |
|
|
18
|
+
| Player data | `/cloud/v2/universes/{u}/data-stores/{store}/entries/{key}` (`If-Match` etag) | `universe-datastores.objects:*` | Stable |
|
|
19
|
+
| Publish place at update time | `POST /universes/v1/{u}/places/{p}/versions?versionType=Published` | `universe-places:write` | Beta |
|
|
20
|
+
| Restart / bleed-off servers | `POST /cloud/v2/universes/{u}:restartServers` | `universe:write` | Stable |
|
|
21
|
+
| In-game broadcast | `POST /cloud/v2/universes/{u}:publishMessage` | `universe-messaging-service:publish` | Stable |
|
|
22
|
+
| Player notifications | `POST /cloud/v2/users/{userId}/notifications` | `user.user-notification:write` | Stable |
|
|
23
|
+
|
|
24
|
+
## Caveats
|
|
25
|
+
|
|
26
|
+
- **Experimental endpoints** (icon, thumbnails, name/description, events) can change
|
|
27
|
+
without notice. They're wrapped in one place (`src/openCloud.ts`) so a break is a
|
|
28
|
+
one-file fix.
|
|
29
|
+
- **Silent no-ops:** Roblox notes that a key missing a scope may return success
|
|
30
|
+
without applying a change. Every write in UpdateDay is followed by a read-back.
|
|
31
|
+
- **Event thumbnails:** the create body takes a `thumbnails` array, but the spec
|
|
32
|
+
doesn't document its item shape. Needs a live test.
|
|
33
|
+
- **Language code:** the icon / name endpoints are per language. Writing the
|
|
34
|
+
game's source language (usually `en`) updates the primary icon / name. Needs a live
|
|
35
|
+
test to confirm.
|
|
36
|
+
- **Rate limits:** about 3 writes/sec for dev products, 5/sec for passes, and 30/min for place
|
|
37
|
+
publishing. Bulk sales are throttled.
|
|
38
|
+
|
|
39
|
+
## Live findings (2026-10-28, private test game)
|
|
40
|
+
|
|
41
|
+
- **Main icon:** the API key can't set the source-language icon (error 26). The source-language swap works:
|
|
42
|
+
switch the source, upload the icon for the old source language, delete it before switching back.
|
|
43
|
+
- **Name / description:** `PATCH /cloud/v2/universes/{u}/places/{rootPlace}` with `universe.place:write`
|
|
44
|
+
changes the public name and description.
|
|
45
|
+
- **Automatic translation:** Roblox auto-translates a new description into every supported language
|
|
46
|
+
within seconds. Restoring the source description to *empty* does **not** clear those translations;
|
|
47
|
+
they have to be blanked per language with `PATCH .../name-description/games/{u}`. Any name or
|
|
48
|
+
description change must check the translations after restoring.
|
|
49
|
+
- **Sales:** passes and products PATCH + read-back work; 28 items took about 33 s each way.
|
|
50
|
+
- **Events:** creation works on a private game.
|