updateday 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -2
  3. package/assets/hourglass/10m.png +0 -0
  4. package/assets/hourglass/12h.png +0 -0
  5. package/assets/hourglass/15m.png +0 -0
  6. package/assets/hourglass/1h.png +0 -0
  7. package/assets/hourglass/24h.png +0 -0
  8. package/assets/hourglass/2h.png +0 -0
  9. package/assets/hourglass/30m.png +0 -0
  10. package/assets/hourglass/3h.png +0 -0
  11. package/assets/hourglass/45m.png +0 -0
  12. package/assets/hourglass/4h.png +0 -0
  13. package/assets/hourglass/5h.png +0 -0
  14. package/assets/hourglass/5m.png +0 -0
  15. package/assets/hourglass/6h.png +0 -0
  16. package/assets/hourglass/default.png +0 -0
  17. package/assets/hourglass/now.png +0 -0
  18. package/assets/hourglass-styles/blocky.png +0 -0
  19. package/assets/hourglass-styles/classic.png +0 -0
  20. package/assets/hourglass-styles/glow.png +0 -0
  21. package/assets/hourglass-styles/outline.png +0 -0
  22. package/assets/hourglass-styles/pixel.png +0 -0
  23. package/assets/hourglass-styles/rounded.png +0 -0
  24. package/bin/updateday.js +5 -0
  25. package/dist/bundle.js +54 -0
  26. package/dist/cli.js +254 -0
  27. package/dist/config.js +261 -0
  28. package/dist/doctor.js +119 -0
  29. package/dist/experiments.js +349 -0
  30. package/dist/gamePage.js +143 -0
  31. package/dist/gifts.js +55 -0
  32. package/dist/history.js +68 -0
  33. package/dist/icons.js +216 -0
  34. package/dist/labels.js +64 -0
  35. package/dist/mcp.js +302 -0
  36. package/dist/moderation.js +86 -0
  37. package/dist/notify.js +72 -0
  38. package/dist/openCloud.js +211 -0
  39. package/dist/playerData.js +109 -0
  40. package/dist/preflight.js +202 -0
  41. package/dist/runner.js +360 -0
  42. package/dist/schedule.js +162 -0
  43. package/dist/server.js +929 -0
  44. package/dist/state.js +29 -0
  45. package/dist/swap.js +196 -0
  46. package/dist/zip.js +82 -0
  47. package/docs/AGENTS.md +123 -0
  48. package/docs/COVERAGE.md +50 -0
  49. package/docs/GUIDE.md +288 -0
  50. package/docs/hourglass-styles.png +0 -0
  51. package/docs/screenshot.png +0 -0
  52. package/example/multi-game.json +16 -0
  53. package/example/updateday.json +37 -0
  54. package/package.json +63 -5
  55. package/roblox/UpdateDayBanner.client.luau +159 -0
  56. package/roblox/UpdateDayCountdown.server.luau +48 -0
  57. package/roblox/UpdateDayGifts.luau +119 -0
  58. package/roblox/UpdateDayListener.luau +43 -0
  59. package/templates/github-actions/updateday.yml +35 -0
  60. package/ui/core.js +325 -0
  61. package/ui/countdown.js +775 -0
  62. package/ui/events.js +249 -0
  63. package/ui/gifts.js +215 -0
  64. package/ui/history.js +156 -0
  65. package/ui/index.html +530 -0
  66. package/ui/jsonEditor.js +417 -0
  67. package/ui/page.js +94 -0
  68. package/ui/players.js +256 -0
  69. package/ui/run.js +391 -0
  70. package/ui/sale.js +366 -0
  71. package/ui/settings.js +414 -0
  72. package/ui/shell.js +193 -0
package/ui/sale.js ADDED
@@ -0,0 +1,366 @@
1
+ const SALE_CHIPS = [10, 20, 25, 30, 40, 50];
2
+ const DEFAULT_SALE = { percentOff: 30, durationHours: 48, gamePasses: "all", developerProducts: "all" };
3
+ const DEFAULT_TAG = "[{percent}% OFF]";
4
+ const EXAMPLE_ITEM = "2x Power";
5
+
6
+ let saleItems = { game: null, items: null, error: null };
7
+
8
+ function sharedSale() {
9
+ UD.draft.sale = UD.draft.sale || { ...DEFAULT_SALE };
10
+ return UD.draft.sale;
11
+ }
12
+
13
+ function salePercent() {
14
+ return UD.draft.sale?.percentOff ?? DEFAULT_SALE.percentOff;
15
+ }
16
+
17
+ function salePrice(price, percent) {
18
+ return Math.max(1, Math.round(price * (1 - percent / 100)));
19
+ }
20
+
21
+ function ownSale(game) {
22
+ if (!game || game.sale === false) {
23
+ return null;
24
+ }
25
+ game.sale = game.sale || {};
26
+ return game.sale;
27
+ }
28
+
29
+ function pickedIds(game, key) {
30
+ const own = game?.sale ? game.sale[key] : undefined;
31
+ return Array.isArray(own) ? own : null;
32
+ }
33
+
34
+ function pickMode(game) {
35
+ return pickedIds(game, "gamePasses") || pickedIds(game, "developerProducts") ? "pick" : "all";
36
+ }
37
+
38
+ function saleWindow() {
39
+ const sale = UD.draft.sale || {};
40
+ const start = sale.startsAt ? new Date(sale.startsAt) : gameUpdateAt(currentGame());
41
+ const end = sale.endsAt ? new Date(sale.endsAt) : new Date(start.getTime() + (sale.durationHours ?? DEFAULT_SALE.durationHours) * 36e5);
42
+ return { start, end };
43
+ }
44
+
45
+ function summaryHtml() {
46
+ const { start, end } = saleWindow();
47
+ const follows = UD.draft.sale?.startsAt ? "" : " Starts with each game's update.";
48
+ return `<b>${salePercent()}% off</b> from ${h(formatWhen(start))} to ${h(formatWhen(end))} (${formatSpan(end - start)}).${follows}`;
49
+ }
50
+
51
+ function tagExample() {
52
+ const sale = UD.draft.sale || {};
53
+ const tag = (sale.nameTag ?? "").replace(/\{percent\}/g, String(salePercent())).trim();
54
+ if (!tag) {
55
+ return "";
56
+ }
57
+ return sale.nameTagPosition === "suffix" ? `${EXAMPLE_ITEM} ${tag}` : `${tag} ${EXAMPLE_ITEM}`;
58
+ }
59
+
60
+ function discountHtml() {
61
+ const percent = salePercent();
62
+ const matched = SALE_CHIPS.includes(percent);
63
+ const chips = SALE_CHIPS.map((pct) => `<button type="button" class="chip ${pct === percent ? "active" : ""}" data-pct="${pct}" aria-pressed="${pct === percent}">${pct}%</button>`);
64
+ return `
65
+ <div class="card-section">
66
+ <div class="section-title">Discount</div>
67
+ <div class="chips">
68
+ ${chips.join("")}
69
+ <input type="number" id="saPercent" min="1" max="99" placeholder="Other %" aria-label="Other discount percent" value="${matched ? "" : percent}" style="width:110px">
70
+ </div>
71
+ </div>`;
72
+ }
73
+
74
+ function whenHtml() {
75
+ const sale = UD.draft.sale || {};
76
+ return `
77
+ <div class="card-section">
78
+ <div class="card-head" style="margin-bottom:10px">
79
+ <div class="section-title" style="margin:0">When</div>
80
+ <button class="link-btn" id="saAdmin">Use the admin-abuse hour</button>
81
+ </div>
82
+ <div class="row">
83
+ <label class="field"><span>Starts</span><input type="datetime-local" id="saStarts" value="${sale.startsAt ? toLocalInput(sale.startsAt) : ""}"></label>
84
+ <label class="field"><span>Ends</span><input type="datetime-local" id="saEnds" value="${sale.endsAt ? toLocalInput(sale.endsAt) : ""}"></label>
85
+ </div>
86
+ <div class="hint" style="margin:-4px 0 12px">Leave Starts empty to start at the update, and Ends empty to use a length.</div>
87
+ <label class="field" ${sale.endsAt ? "hidden" : ""}><span>Or end after (hours)</span><input type="number" id="saHours" min="0.05" max="720" step="0.5" value="${sale.durationHours ?? DEFAULT_SALE.durationHours}"></label>
88
+ <div class="callout callout-info" id="saSummary">${summaryHtml()}</div>
89
+ </div>`;
90
+ }
91
+
92
+ function tagHtml() {
93
+ const sale = UD.draft.sale || {};
94
+ return `
95
+ <div class="card-section">
96
+ ${switchHtml("saTagOn", Boolean(sale.nameTag), "Tag names during the sale", "Original names are put back when the sale ends.")}
97
+ <div class="revealed" id="saTagFields" ${sale.nameTag ? "" : "hidden"}>
98
+ <div class="row">
99
+ <label class="field"><span>Tag</span><input type="text" id="saTag" placeholder="${DEFAULT_TAG}" value="${h(sale.nameTag ?? DEFAULT_TAG)}"><span class="hint">{percent} becomes the discount.</span></label>
100
+ <label class="field"><span>Where</span>
101
+ <select id="saTagPos">
102
+ <option value="prefix" ${sale.nameTagPosition !== "suffix" ? "selected" : ""}>Before the name</option>
103
+ <option value="suffix" ${sale.nameTagPosition === "suffix" ? "selected" : ""}>After the name</option>
104
+ </select>
105
+ </label>
106
+ </div>
107
+ <div class="hint">Example: <b id="saTagExample">${h(tagExample())}</b></div>
108
+ </div>
109
+ </div>`;
110
+ }
111
+
112
+ function itemRowHtml(item, mode, picked, percent) {
113
+ const sellable = item.isForSale && item.price !== null;
114
+ const checked = mode === "all" ? sellable : (picked[item.kind] || []).includes(item.id);
115
+ const price = sellable ? `<s>R$ ${item.price}</s><b>R$ ${salePrice(item.price, percent)}</b>` : `<span class="hint">Not for sale</span>`;
116
+ const locked = mode === "all" || !sellable ? "disabled" : "";
117
+ return `
118
+ <label class="item-row ${checked ? "" : "off"}">
119
+ <input type="checkbox" data-kind="${item.kind}" data-id="${item.id}" ${checked ? "checked" : ""} ${locked} aria-label="${h(item.name)}">
120
+ ${item.iconUrl ? `<img src="${h(item.iconUrl)}" alt="" loading="lazy">` : `<img alt="">`}
121
+ <span style="min-width:0"><span class="kind">${item.kind === "gamePass" ? "Game pass" : "Product"}</span><br>${h(item.name)}</span>
122
+ <span class="price">${price}</span>
123
+ </label>`;
124
+ }
125
+
126
+ function itemsBodyHtml() {
127
+ const game = currentGame();
128
+ if (!game?.universeId) {
129
+ return emptyState("Add the game link first", "Its passes and products load once UpdateDay knows the game.");
130
+ }
131
+ if (saleItems.game !== game.universeId) {
132
+ return `<div class="items">${`<div class="item-row"><span></span><span class="skeleton" style="height:36px"></span><span class="skeleton" style="height:14px"></span><span></span></div>`.repeat(4)}</div>`;
133
+ }
134
+ if (saleItems.error) {
135
+ const retry = /API key/.test(saleItems.error) ? "" : `<button class="btn btn-sm" id="saRetry" style="margin-top:10px">Try again</button>`;
136
+ return `${errorBox(saleItems.error)}${retry}`;
137
+ }
138
+ const mode = pickMode(game);
139
+ const picked = { gamePass: pickedIds(game, "gamePasses"), developerProduct: pickedIds(game, "developerProducts") };
140
+ const rows = saleItems.items.map((item) => itemRowHtml(item, mode, picked, salePercent())).join("");
141
+ return rows ? `<div class="items">${rows}</div>` : emptyState("No passes or products", "This game has nothing to put on sale yet.");
142
+ }
143
+
144
+ function itemsCardHtml() {
145
+ const game = currentGame();
146
+ if (game?.sale === false) {
147
+ return `
148
+ <div class="card">
149
+ <div class="card-head"><div><h2>Items</h2></div></div>
150
+ ${emptyState("No sale for this game", "This game is left out of the sale.", `<button class="btn btn-sm" id="saInclude">Include it again</button>`)}
151
+ </div>`;
152
+ }
153
+ const mode = pickMode(game);
154
+ return `
155
+ <div class="card">
156
+ <div class="items-head">
157
+ <div><h2>Items</h2><div class="hint">Prices shown after the discount.</div></div>
158
+ <div style="display:flex;gap:8px;align-items:center">
159
+ ${segmentedHtml("saleMode", [["all", "Everything for sale"], ["pick", "Only ticked"]], mode)}
160
+ <button class="icon-btn" id="saReload" aria-label="Reload items" title="Reload items">↻</button>
161
+ </div>
162
+ </div>
163
+ <div id="saItems">${itemsBodyHtml()}</div>
164
+ <div class="hint" style="margin-top:12px">Every price and name is saved before it changes, read back after, and put back when the sale ends.</div>
165
+ <button class="link-btn" id="saExclude" style="margin-top:8px">Leave this game out of the sale</button>
166
+ </div>`;
167
+ }
168
+
169
+ async function loadSaleItems(reload) {
170
+ const game = currentGame();
171
+ if (!game?.universeId) {
172
+ return;
173
+ }
174
+ try {
175
+ const result = await api("GET", `/api/items?${gameQuery()}${reload ? "&refresh=1" : ""}`);
176
+ saleItems = { game: game.universeId, items: result.items, error: null };
177
+ } catch (error) {
178
+ saleItems = { game: game.universeId, items: null, error: error.message };
179
+ }
180
+ const box = $("saItems");
181
+ if (box) {
182
+ box.innerHTML = itemsBodyHtml();
183
+ bindItems();
184
+ }
185
+ }
186
+
187
+ function bindItems() {
188
+ const game = currentGame();
189
+ $("saRetry")?.addEventListener("click", () => {
190
+ saleItems.game = null;
191
+ $("saItems").innerHTML = itemsBodyHtml();
192
+ loadSaleItems(true);
193
+ });
194
+ for (const check of document.querySelectorAll("#saItems input[type=checkbox]")) {
195
+ check.addEventListener("change", () => {
196
+ const own = ownSale(game);
197
+ const key = check.dataset.kind === "gamePass" ? "gamePasses" : "developerProducts";
198
+ const other = key === "gamePasses" ? "developerProducts" : "gamePasses";
199
+ const id = Number(check.dataset.id);
200
+ own[key] = (pickedIds(game, key) || []).filter((existing) => existing !== id);
201
+ if (check.checked) {
202
+ own[key].push(id);
203
+ }
204
+ if (!pickedIds(game, other)) {
205
+ own[other] = [];
206
+ }
207
+ check.closest(".item-row").classList.toggle("off", !check.checked);
208
+ markDirty();
209
+ });
210
+ }
211
+ }
212
+
213
+ function setPickMode(mode) {
214
+ const game = currentGame();
215
+ const own = ownSale(game);
216
+ if (mode === "pick") {
217
+ const items = saleItems.items || [];
218
+ own.gamePasses = items.filter((item) => item.kind === "gamePass" && item.isForSale).map((item) => item.id);
219
+ own.developerProducts = items.filter((item) => item.kind === "developerProduct" && item.isForSale).map((item) => item.id);
220
+ } else {
221
+ delete own.gamePasses;
222
+ delete own.developerProducts;
223
+ if (Object.keys(own).length === 0) {
224
+ delete game.sale;
225
+ }
226
+ }
227
+ markDirty();
228
+ $("saItems").innerHTML = itemsBodyHtml();
229
+ bindItems();
230
+ }
231
+
232
+ function refreshSaleBits() {
233
+ const summary = $("saSummary");
234
+ if (summary) {
235
+ summary.innerHTML = summaryHtml();
236
+ }
237
+ const example = $("saTagExample");
238
+ if (example) {
239
+ example.textContent = tagExample();
240
+ }
241
+ }
242
+
243
+ function setDate(key, value) {
244
+ const sale = sharedSale();
245
+ const iso = value ? fromLocalInput(value) : null;
246
+ if (iso) {
247
+ sale[key] = iso;
248
+ } else {
249
+ delete sale[key];
250
+ }
251
+ markDirty();
252
+ }
253
+
254
+ function bindSale(root) {
255
+ root.querySelector("#saOn").addEventListener("change", (event) => {
256
+ if (event.target.checked) {
257
+ sharedSale();
258
+ } else {
259
+ delete UD.draft.sale;
260
+ }
261
+ markDirty();
262
+ renderSale(root);
263
+ });
264
+ if (!UD.draft.sale) {
265
+ return;
266
+ }
267
+ const sale = sharedSale();
268
+ const setPercent = (value) => {
269
+ if (value > 0 && value < 100) {
270
+ sale.percentOff = value;
271
+ markDirty();
272
+ renderSale(root);
273
+ }
274
+ };
275
+ for (const chip of root.querySelectorAll("[data-pct]")) {
276
+ chip.addEventListener("click", () => setPercent(Number(chip.dataset.pct)));
277
+ }
278
+ root.querySelector("#saPercent").addEventListener("change", (event) => setPercent(Number(event.target.value)));
279
+ root.querySelector("#saStarts").addEventListener("change", (event) => {
280
+ setDate("startsAt", event.target.value);
281
+ refreshSaleBits();
282
+ });
283
+ root.querySelector("#saEnds").addEventListener("change", (event) => {
284
+ setDate("endsAt", event.target.value);
285
+ renderSale(root);
286
+ });
287
+ root.querySelector("#saHours").addEventListener("input", (event) => {
288
+ sale.durationHours = Number(event.target.value);
289
+ markDirty();
290
+ refreshSaleBits();
291
+ });
292
+ root.querySelector("#saAdmin").addEventListener("click", () => {
293
+ delete sale.startsAt;
294
+ delete sale.endsAt;
295
+ sale.durationHours = (UD.draft.adminAbuseMinutes ?? 60) / 60;
296
+ markDirty();
297
+ renderSale(root);
298
+ toast(`Sale set to the ${UD.draft.adminAbuseMinutes ?? 60}-minute admin-abuse window`, "success");
299
+ });
300
+ root.querySelector("#saTagOn").addEventListener("change", (event) => {
301
+ if (event.target.checked) {
302
+ sale.nameTag = sale.nameTag || DEFAULT_TAG;
303
+ } else {
304
+ delete sale.nameTag;
305
+ delete sale.nameTagPosition;
306
+ }
307
+ root.querySelector("#saTagFields").hidden = !event.target.checked;
308
+ markDirty();
309
+ refreshSaleBits();
310
+ });
311
+ root.querySelector("#saTag").addEventListener("input", (event) => {
312
+ sale.nameTag = event.target.value;
313
+ markDirty();
314
+ refreshSaleBits();
315
+ });
316
+ root.querySelector("#saTagPos").addEventListener("change", (event) => {
317
+ sale.nameTagPosition = event.target.value;
318
+ markDirty();
319
+ refreshSaleBits();
320
+ });
321
+ const game = currentGame();
322
+ root.querySelector("#saInclude")?.addEventListener("click", () => {
323
+ delete game.sale;
324
+ markDirty();
325
+ renderSale(root);
326
+ });
327
+ root.querySelector("#saExclude")?.addEventListener("click", () => {
328
+ game.sale = false;
329
+ markDirty();
330
+ renderSale(root);
331
+ });
332
+ bindSegmented(root, "saleMode", setPickMode);
333
+ root.querySelector("#saReload")?.addEventListener("click", () => {
334
+ saleItems.game = null;
335
+ $("saItems").innerHTML = itemsBodyHtml();
336
+ loadSaleItems(true);
337
+ });
338
+ bindItems();
339
+ }
340
+
341
+ function renderSale(root) {
342
+ const on = Boolean(UD.draft.sale);
343
+ root.innerHTML = `
344
+ <div class="grid-2">
345
+ <div class="stack">
346
+ <div class="card">
347
+ ${switchHtml("saOn", on, "Sale at update time", "Discount game passes and developer products, then put every price back. Shared by all games.")}
348
+ ${on ? discountHtml() + whenHtml() + tagHtml() : ""}
349
+ </div>
350
+ </div>
351
+ <div class="stack">${on ? itemsCardHtml() : `<div class="card">${emptyState("No sale planned", "Turn the sale on to pick a discount and the items in it.")}</div>`}</div>
352
+ </div>`;
353
+ bindSale(root);
354
+ const game = currentGame();
355
+ if (on && game?.universeId && game.sale !== false && saleItems.game !== game.universeId) {
356
+ loadSaleItems(false);
357
+ }
358
+ }
359
+
360
+ UD.views.sale = {
361
+ label: "Sale",
362
+ render: renderSale,
363
+ refresh() {
364
+ refreshSaleBits();
365
+ },
366
+ };