@onceworlds/cli 0.2.1 → 0.4.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/README.md +32 -10
- package/dist/cli.js +703 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,8 +9,9 @@ players) with no server code.
|
|
|
9
9
|
Deploys run from GitHub Actions: GitHub vouches for the repo, so there are no API
|
|
10
10
|
keys. The easiest setup is to import the repo at https://onceworlds.com/dashboard/new,
|
|
11
11
|
which adds `onceworlds.json` and `.github/workflows/onceworlds.yml` for you (that
|
|
12
|
-
workflow also builds with npm, pnpm, yarn or bun when there's a build script).
|
|
13
|
-
|
|
12
|
+
workflow also builds with npm, pnpm, yarn or bun when there's a build script). To write
|
|
13
|
+
the same files into a folder before it is on GitHub, run `npx @onceworlds/cli init` there.
|
|
14
|
+
By hand, the minimum is:
|
|
14
15
|
|
|
15
16
|
```yaml
|
|
16
17
|
name: Deploy to Onceworlds
|
|
@@ -33,12 +34,13 @@ jobs:
|
|
|
33
34
|
|
|
34
35
|
Pushes to the production branch (`main` unless you change it in the dashboard or
|
|
35
36
|
`onceworlds.json`) go live at `onceworlds.com/play/<slug>`; other branches get a
|
|
36
|
-
preview URL in the job log. Log in to https://onceworlds.com with
|
|
37
|
-
account that
|
|
38
|
-
|
|
37
|
+
preview URL in the job log. Log in to https://onceworlds.com with a GitHub
|
|
38
|
+
account that has write access to the repo before the first deploy. Install the
|
|
39
|
+
Onceworlds GitHub App on that repo to use the dashboard and open the game while
|
|
40
|
+
private. If a deploy fails, the dashboard shows why.
|
|
39
41
|
|
|
40
|
-
New games start private: only
|
|
41
|
-
|
|
42
|
+
New games start private: only accounts with write access to the repo can play
|
|
43
|
+
them. To share one, open it in the dashboard and pick Unlisted (anyone
|
|
42
44
|
with the link) or Public (also listed on Onceworlds).
|
|
43
45
|
|
|
44
46
|
## Project file
|
|
@@ -62,18 +64,38 @@ with the link) or Public (also listed on Onceworlds).
|
|
|
62
64
|
`index.html`. `productionBranch` (default `main`) picks the branch that goes live;
|
|
63
65
|
the dashboard can change it too, and the file wins again only when this value changes.
|
|
64
66
|
|
|
65
|
-
|
|
67
|
+
`controls` (`[{ "keys": ["W", "A", "S", "D"], "action": "Move" }]`, up to 12) and `help` (up to 400
|
|
68
|
+
characters) fill "How to play" in the platform menu. The rest fills in the game page: `genre`, a square `icon` (at least 128×128),
|
|
66
69
|
16:9 `thumbnails` (at least 480 wide; the first is the cover) and `badges` (`id`,
|
|
67
|
-
`name`, `description` and an optional square `icon`).
|
|
70
|
+
`name`, `description` and an optional square `icon`). A game page holds at most 5
|
|
71
|
+
thumbnails and videos together, and a game at most 50 badges. Images are PNG, JPEG, WebP
|
|
68
72
|
or GIF up to 5 MB, with paths relative to `onceworlds.json`. Live deploys apply
|
|
69
73
|
these fields only when they change, so edits made in the dashboard last until then.
|
|
70
74
|
|
|
75
|
+
Make the art before the first deploy: players see the icon and thumbnails before the game, and a game
|
|
76
|
+
without them gets a plain colored placeholder. The icon is a simple, wordless 512×512 square; the thumbnails
|
|
77
|
+
are three to five 1280×720 pictures of the game being played, the best one first (it is the cover). Capture them
|
|
78
|
+
from the running game and commit them under `store/`. `onceworlds doctor` says which fields are missing.
|
|
79
|
+
|
|
71
80
|
## Commands
|
|
72
81
|
|
|
82
|
+
- `onceworlds init [dir]` sets a game folder up, with the files the dashboard's import commits. It writes
|
|
83
|
+
`onceworlds.json` (the link and name come from `package.json`'s name or the folder's; `dir` is your build's
|
|
84
|
+
output folder when `package.json` has a build script, else `.` or the folder with your `index.html`),
|
|
85
|
+
`.github/workflows/onceworlds.yml`, and the guide for AI coding tools as `AGENTS.md` with a `CLAUDE.md` that
|
|
86
|
+
imports it (next to a `AGENTS.md` of your own it writes `ONCEWORLDS.md` and adds a pointer to yours). It never
|
|
87
|
+
overwrites a file you have: `--force` replaces only what init writes, never your own `AGENTS.md` or `CLAUDE.md`.
|
|
88
|
+
Then commit, push, and import the repo at https://onceworlds.com/dashboard/new.
|
|
73
89
|
- `onceworlds deploy [dir]` uploads only the files that changed and publishes the game
|
|
74
90
|
(the workflow runs it). It lists every file it skipped because Onceworlds can't serve it.
|
|
75
91
|
- `onceworlds dev [dir]` deploys to a local Onceworlds platform (for platform development)
|
|
76
|
-
and redeploys on every save.
|
|
92
|
+
and redeploys on every save. `--players 3` also prints links for players 2 and 3 (`?player=N`): on a
|
|
93
|
+
local platform each is another player, so one person can fill a lobby.
|
|
94
|
+
- `onceworlds doctor [dir]` looks through the game's files for what games often ship with: no viewport
|
|
95
|
+
tag, text fields under 16px (Safari zooms the page when one is focused), `confirm()` and other dialogs
|
|
96
|
+
(blocked in the game frame), localStorage (gone on reload), and, for games with rooms, `Date.now()` for
|
|
97
|
+
shared time and no use of the platform's lobby. It only suggests: nothing in it stops a deploy
|
|
98
|
+
(`--strict` exits with an error on likely bugs, for CI).
|
|
77
99
|
|
|
78
100
|
Games talk to the platform through the injected `window.onceworlds` SDK. The
|
|
79
101
|
starter template's `AGENTS.md` documents it for AI coding tools.
|
package/dist/cli.js
CHANGED
|
@@ -1,28 +1,230 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { watch } from "node:fs";
|
|
7
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
8
|
-
import { join, resolve, sep } from "node:path";
|
|
7
|
+
import { readdir as readdir2, readFile as readFile2, stat as stat2 } from "node:fs/promises";
|
|
8
|
+
import { join as join2, resolve, sep } from "node:path";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
10
|
|
|
11
|
+
// src/doctor.ts
|
|
12
|
+
var ROOM_MAX_PLAYERS = 30;
|
|
13
|
+
var MINIFIED_LINE = 400;
|
|
14
|
+
var lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
15
|
+
function looksMinified(text) {
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
return text.length > 2e4 && text.length / lines.length > MINIFIED_LINE;
|
|
18
|
+
}
|
|
19
|
+
function cssIn(file) {
|
|
20
|
+
if (/\.css$/i.test(file.path)) return [{ css: file.text, offset: 0 }];
|
|
21
|
+
if (!/\.html?$/i.test(file.path)) return [];
|
|
22
|
+
const blocks = [];
|
|
23
|
+
for (const match of file.text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) blocks.push({ css: match[1], offset: (match.index ?? 0) + match[0].indexOf(match[1]) });
|
|
24
|
+
return blocks;
|
|
25
|
+
}
|
|
26
|
+
function smallFields(file) {
|
|
27
|
+
const found = [];
|
|
28
|
+
for (const { css, offset } of cssIn(file)) {
|
|
29
|
+
for (const rule of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
30
|
+
const selector = rule[1];
|
|
31
|
+
if (!/(^|[\s,>+~(])(input|textarea|select)\b/i.test(selector) && !/\.[\w-]*(input|field|textbox)\b/i.test(selector)) continue;
|
|
32
|
+
if (/type\s*=\s*["']?(range|checkbox|radio|color|button|submit|file)/i.test(selector)) continue;
|
|
33
|
+
const size = /(?:^|[;\s])font-size\s*:\s*(\d+(?:\.\d+)?)px/i.exec(rule[2]) ?? /(?:^|[;\s])font\s*:\s*[^;]*?\b(\d+(?:\.\d+)?)px/i.exec(rule[2]);
|
|
34
|
+
if (!size || Number(size[1]) >= 16) continue;
|
|
35
|
+
const at = offset + (rule.index ?? 0) + rule[0].indexOf(size[0]);
|
|
36
|
+
found.push({
|
|
37
|
+
file: file.path,
|
|
38
|
+
line: lineOf(file.text, at),
|
|
39
|
+
level: "warn",
|
|
40
|
+
id: "field-size",
|
|
41
|
+
message: `A text field at ${size[1]}px: Safari on iPhone and iPad zooms the whole page when it's focused. Use at least 16px.`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
var LINE_RULES = [
|
|
48
|
+
{
|
|
49
|
+
id: "dialogs",
|
|
50
|
+
level: "warn",
|
|
51
|
+
test: /(?<![\w$])(?:window\s*\.\s*)?(alert|confirm|prompt)\s*\(/,
|
|
52
|
+
// `confirm() {` (a method), `function prompt(`, `.confirm(` on some object of the game's own.
|
|
53
|
+
except: /(?<![\w$])(alert|confirm|prompt)\s*\([^()]*\)\s*\{|function\s+(alert|confirm|prompt)\b|(?<!window)\.\s*(alert|confirm|prompt)\s*\(/,
|
|
54
|
+
message: "alert(), confirm() and prompt() are blocked in the game frame (confirm() answers false at once). Draw your own message or dialog."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "fullscreen",
|
|
58
|
+
level: "note",
|
|
59
|
+
test: /\.(webkitR|r)equestFullscreen\s*\(/,
|
|
60
|
+
message: "Use onceworlds.ui.requestFullscreen(): the platform keeps its menu and chat available in fullscreen, and handles iPhone."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "storage",
|
|
64
|
+
level: "note",
|
|
65
|
+
test: /\b(localStorage|sessionStorage)\b|document\.cookie/,
|
|
66
|
+
message: "localStorage, sessionStorage and cookies are kept in memory in the game frame and are gone on reload (fine for caches and a fallback outside the platform). Use onceworlds.save.set/get for progress and settings that should stay."
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
function checkListing(config, file = "onceworlds.json") {
|
|
70
|
+
const found = [];
|
|
71
|
+
const note = (id, message) => found.push({ file, line: 1, level: "note", id, message });
|
|
72
|
+
const filled = (value) => typeof value === "string" && value.trim() !== "";
|
|
73
|
+
if (!filled(config.icon)) {
|
|
74
|
+
note("listing-icon", `No "icon": the game's card shows a plain colored placeholder. Add a square image (512\xD7512, simple, no words), like "icon": "store/icon.png".`);
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(config.thumbnails) || config.thumbnails.length === 0) {
|
|
77
|
+
note("listing-thumbnails", 'No "thumbnails": the game page and its card have no cover. Add 3-5 16:9 pictures (1280\xD7720) of the game being played; the first is the cover.');
|
|
78
|
+
}
|
|
79
|
+
if (!filled(config.genre)) note("listing-genre", `No "genre": players can't find the game under a Discover genre. Pick one of the genres in AGENTS.md.`);
|
|
80
|
+
const noControls = !Array.isArray(config.controls) || config.controls.length === 0;
|
|
81
|
+
if (noControls && !filled(config.help)) {
|
|
82
|
+
note("listing-help", `No "controls" or "help": the menu's How to play is empty. List every key or button the game uses and a line on how to win.`);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
function checkGame(files) {
|
|
87
|
+
const found = [];
|
|
88
|
+
const code = files.filter((f) => /\.(html?|m?js|css)$/i.test(f.path));
|
|
89
|
+
const index = code.find((f) => f.path === "index.html");
|
|
90
|
+
if (index && !/<meta[^>]+name\s*=\s*["']viewport["']/i.test(index.text)) {
|
|
91
|
+
found.push({
|
|
92
|
+
file: index.path,
|
|
93
|
+
line: 1,
|
|
94
|
+
level: "warn",
|
|
95
|
+
id: "viewport",
|
|
96
|
+
message: 'No viewport meta tag: phones show the page zoomed out. Add <meta name="viewport" content="width=device-width, initial-scale=1">.'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
for (const file of code) {
|
|
100
|
+
found.push(...smallFields(file));
|
|
101
|
+
if (looksMinified(file.text) || /\.css$/i.test(file.path)) continue;
|
|
102
|
+
const lines = file.text.split("\n");
|
|
103
|
+
for (const rule of LINE_RULES) {
|
|
104
|
+
const first = lines.findIndex((line) => !/^\s*(\/\/|\*|\/\*)/.test(line) && rule.test.test(line) && !rule.except?.test(line));
|
|
105
|
+
if (first !== -1) found.push({ file: file.path, line: first + 1, level: rule.level, id: rule.id, message: rule.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const all = code.map((f) => f.text).join("\n");
|
|
109
|
+
if (/\brooms\s*\.\s*join\s*\(/.test(all)) {
|
|
110
|
+
const host = code.find((f) => /\brooms\s*\.\s*join\s*\(/.test(f.text));
|
|
111
|
+
const at = lineOf(host.text, host.text.search(/\brooms\s*\.\s*join\s*\(/));
|
|
112
|
+
if (!/\b(lobby\s*:|startMatch|matchstart|setReady|minPlayers)/.test(all)) {
|
|
113
|
+
found.push({
|
|
114
|
+
file: host.path,
|
|
115
|
+
line: at,
|
|
116
|
+
level: "note",
|
|
117
|
+
id: "matches",
|
|
118
|
+
message: `This joins a room but doesn't use the platform's lobby or match lifecycle. That's right for a world people drop in and out of; a game with rounds gets ready-up, a countdown, spectators and pausing from rooms.join({ lobby: 'card' }) (see AGENTS.md, "Matches").`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (/\bDate\s*\.\s*now\s*\(/.test(all) && !/\bonceworlds\s*\.\s*now\s*\(|\bow\s*\.\s*now\s*\(|\bmatchNow\s*\(/.test(all)) {
|
|
122
|
+
found.push({
|
|
123
|
+
file: host.path,
|
|
124
|
+
line: at,
|
|
125
|
+
level: "note",
|
|
126
|
+
id: "clock",
|
|
127
|
+
message: "Time from Date.now() differs between players' devices. Use onceworlds.now() for anything players share, and room.matchNow() for a match's deadlines."
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const size = /\bmaxPlayers\s*:\s*(\d+)\b/g;
|
|
132
|
+
for (const file of code) {
|
|
133
|
+
for (const match of file.text.matchAll(size)) {
|
|
134
|
+
if (Number(match[1]) <= ROOM_MAX_PLAYERS) continue;
|
|
135
|
+
found.push({
|
|
136
|
+
file: file.path,
|
|
137
|
+
line: lineOf(file.text, match.index ?? 0),
|
|
138
|
+
level: "note",
|
|
139
|
+
id: "room-size",
|
|
140
|
+
message: `A room seats at most ${ROOM_MAX_PLAYERS} players: maxPlayers: ${match[1]} gives ${ROOM_MAX_PLAYERS}. The bigger the room, the less each player may send (room.budget), so a full room stays smooth.`
|
|
141
|
+
});
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const keyboardKeys = /\b(ArrowLeft|ArrowRight|ArrowUp|ArrowDown|KeyW|KeyA|KeyS|KeyD)\b|\.key\s*={2,3}\s*['"](?:w|a|s|d|ArrowLeft|ArrowRight|ArrowUp|ArrowDown)['"]|case\s+['"](?:w|a|s|d|ArrowLeft|ArrowRight|ArrowUp|ArrowDown)['"]|keys\s*\.\s*has\s*\(\s*['"](?:w|a|s|d|arrowleft|arrowright|arrowup|arrowdown)['"]/;
|
|
146
|
+
const keyboard = keyboardKeys.test(all);
|
|
147
|
+
if (keyboard && !/\bcontrols\s*\??\.\s*(?:set|stick|touch|pressed)\b|\b(?:set|sync)Controls\s*\(/i.test(all)) {
|
|
148
|
+
const touchable = /\b(pointerdown|touchstart|mousedown|click)\b/.test(all);
|
|
149
|
+
const file = code.find((f) => keyboardKeys.test(f.text));
|
|
150
|
+
found.push({
|
|
151
|
+
file: file.path,
|
|
152
|
+
line: lineOf(file.text, file.text.search(keyboardKeys)),
|
|
153
|
+
level: touchable ? "note" : "warn",
|
|
154
|
+
id: "touch-controls",
|
|
155
|
+
message: touchable ? "Steered by keys, with no on-screen controls: phones show none. Ask for the platform's with onceworlds.controls.set({ stick: 'wasd' }) (see AGENTS.md, phones and tablets)." : "Steered by keys, and nothing responds to touch: on a phone the game can't be played. Ask for the platform's on-screen controls with onceworlds.controls.set({ stick: 'wasd', buttons: [...] })."
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (/\bdevicePixelRatio\b/.test(all) && !/\bsettings\s*\.\s*pixelRatio\b/.test(all)) {
|
|
159
|
+
const file = code.find((f) => /\bdevicePixelRatio\b/.test(f.text));
|
|
160
|
+
found.push({
|
|
161
|
+
file: file.path,
|
|
162
|
+
line: lineOf(file.text, file.text.search(/\bdevicePixelRatio\b/)),
|
|
163
|
+
level: "note",
|
|
164
|
+
id: "pixel-ratio",
|
|
165
|
+
message: "Size canvases with onceworlds.settings.pixelRatio(2): it caps the resolution and follows the player's Graphics setting, so tablets and older phones keep their frame rate."
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return found.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/init.ts
|
|
172
|
+
import { execFileSync } from "node:child_process";
|
|
173
|
+
import { randomBytes } from "node:crypto";
|
|
174
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
175
|
+
import { basename, dirname, join } from "node:path";
|
|
176
|
+
|
|
11
177
|
// ../protocol/src/index.ts
|
|
12
178
|
var OIDC_AUDIENCE = "onceworlds";
|
|
13
179
|
var LIMITS = {
|
|
14
180
|
roomMaxPlayersDefault: 16,
|
|
15
|
-
|
|
181
|
+
/**
|
|
182
|
+
* The most players a room seats. A room is one Durable Object that copies every update a player sends to everyone else, so
|
|
183
|
+
* what it costs grows with the square of its players: 30 is where a room stays well inside what one object serves.
|
|
184
|
+
*/
|
|
185
|
+
roomMaxPlayersCap: 30,
|
|
16
186
|
teamsMax: 8,
|
|
17
187
|
messageBytes: 16 * 1024,
|
|
18
188
|
presenceBytes: 1024,
|
|
19
189
|
stateKeyLength: 64,
|
|
20
190
|
stateValueBytes: 16 * 1024,
|
|
21
191
|
stateKeys: 256,
|
|
192
|
+
/** Private values (room.setPrivate): keys per player, one value, and all of a player's together. */
|
|
193
|
+
privateKeys: 16,
|
|
194
|
+
privateValueBytes: 4 * 1024,
|
|
195
|
+
privateBytesPerPlayer: 8 * 1024,
|
|
196
|
+
/** The most messages a second one player may send a room (a room with more players gives each less: see roomBudget). */
|
|
22
197
|
messagesPerSecond: 60,
|
|
23
|
-
|
|
198
|
+
messagesPerSecondMin: 10,
|
|
199
|
+
/** Bytes one player may send to a room per second, on average (bursts up to 4x): the most, in a small room. */
|
|
24
200
|
roomBytesPerSecond: 128 * 1024,
|
|
201
|
+
roomBytesPerSecondMin: 16 * 1024,
|
|
202
|
+
/** The most presence updates a second one player may send. */
|
|
25
203
|
presenceHz: 20,
|
|
204
|
+
presenceHzMin: 5,
|
|
205
|
+
/**
|
|
206
|
+
* What a room takes in from all its players together each second, presence included (a Durable Object serves about 1000
|
|
207
|
+
* requests a second, and each update is then sent on to everyone else), and the bytes. Each player's share is this divided
|
|
208
|
+
* by the room's capacity, up to the per-player maximums above: rooms of up to 11 players get all of them.
|
|
209
|
+
*/
|
|
210
|
+
roomMessagesPerSecond: 900,
|
|
211
|
+
roomBytesPerSecondTotal: 2 * 1024 * 1024,
|
|
212
|
+
/** A match's countdown before play begins, unless the room is set up otherwise (rooms.join({ countdown })). */
|
|
213
|
+
matchCountdownMs: 3e3,
|
|
214
|
+
matchCountdownMaxMs: 1e4,
|
|
215
|
+
/** A public lobby where half the players are ready waits this long for the rest before starting without them. */
|
|
216
|
+
matchIdleMs: 45e3,
|
|
217
|
+
/** A match that has waited this long for its players (paused, too few connected) is over. */
|
|
218
|
+
matchAbandonMs: 12e4,
|
|
219
|
+
/** A player who hasn't touched the game (pointer, keys, touch, gamepad) this long is idle (room.on('idle')). */
|
|
220
|
+
idleAfterMs: 3e4,
|
|
221
|
+
/** A running match whose connected players have all been idle this long is over: nobody is playing. */
|
|
222
|
+
idleAbandonMs: 5 * 6e4,
|
|
223
|
+
/**
|
|
224
|
+
* Rated matches two accounts on opposite sides may play against each other in one game's ladder per UTC day: past it,
|
|
225
|
+
* their matches aren't rated, so two accounts that keep getting matched in a quiet game can't farm wins.
|
|
226
|
+
*/
|
|
227
|
+
rankedPairMatchesPerDay: 5,
|
|
26
228
|
chatLength: 200,
|
|
27
229
|
chatIntervalMs: 750,
|
|
28
230
|
chatHistory: 50,
|
|
@@ -34,12 +236,24 @@ var LIMITS = {
|
|
|
34
236
|
guestSaveBytesPerGame: 128 * 1024,
|
|
35
237
|
deployFiles: 2e3,
|
|
36
238
|
deployFileBytes: 25 * 1024 * 1024,
|
|
239
|
+
/** HTML files (.html, .htm) get the SDK injected as they're served (streamed: only the first bytes are read for it), so they're held to less than other files. */
|
|
240
|
+
deployHtmlBytes: 10 * 1024 * 1024,
|
|
241
|
+
/** Characters in a deployed file's path. */
|
|
242
|
+
deployPathLength: 200,
|
|
37
243
|
deployTotalBytes: 100 * 1024 * 1024,
|
|
38
244
|
envVars: 50,
|
|
39
245
|
envValueBytes: 4 * 1024,
|
|
246
|
+
/** All of a game's public variables together, as injected into its pages. */
|
|
247
|
+
envTotalBytes: 16 * 1024,
|
|
248
|
+
/** Proxied request bodies: routes that take a file (audio to transcribe, an image to edit). */
|
|
40
249
|
proxyRequestBytes: 1024 * 1024,
|
|
250
|
+
/** Proxied JSON bodies for text, embeddings and image generation. */
|
|
251
|
+
proxyTextRequestBytes: 256 * 1024,
|
|
252
|
+
/** Proxied text-to-speech bodies (priced per character). */
|
|
253
|
+
proxySpeechRequestBytes: 16 * 1024,
|
|
41
254
|
proxyResponseBytes: 10 * 1024 * 1024,
|
|
42
|
-
|
|
255
|
+
/** Output tokens per proxied text call: a request asking for more is lowered to this, one that doesn't say gets it. */
|
|
256
|
+
proxyMaxOutputTokens: 4096,
|
|
43
257
|
proxyCallsPerDay: 2e3,
|
|
44
258
|
proxyCallsPerDayMax: 1e5,
|
|
45
259
|
proxyCallsPerPlayerPerDay: 100,
|
|
@@ -48,13 +262,35 @@ var LIMITS = {
|
|
|
48
262
|
proxyMediaCallsPerDayMax: 1e4,
|
|
49
263
|
proxyMediaCallsPerPlayerPerDay: 20,
|
|
50
264
|
proxyMediaCallsPerNetworkPerDay: 25,
|
|
265
|
+
/**
|
|
266
|
+
* Daily units: each proxied call's estimated tokens (request bytes / 3 plus its output
|
|
267
|
+
* cap) times its model's weight. Per game (the creator can set up to the max), per
|
|
268
|
+
* signed-in player, per guest, and per guests' network.
|
|
269
|
+
*/
|
|
270
|
+
proxyUnitsPerDay: 2e6,
|
|
271
|
+
proxyUnitsPerDayMax: 1e8,
|
|
272
|
+
proxyUnitsPerPlayerPerDay: 25e4,
|
|
273
|
+
proxyUnitsPerGuestPerDay: 1e5,
|
|
274
|
+
proxyUnitsPerNetworkPerDay: 5e5,
|
|
51
275
|
errorReportsPerPage: 10,
|
|
52
276
|
gameNameLength: 60,
|
|
53
277
|
gameDescriptionLength: 1e3,
|
|
54
|
-
galleryItems:
|
|
278
|
+
galleryItems: 5,
|
|
55
279
|
mediaImageBytes: 5 * 1024 * 1024,
|
|
56
280
|
mediaVideoBytes: 30 * 1024 * 1024,
|
|
57
|
-
|
|
281
|
+
/** GIFs are served as they are (never resized), so they're small: at most this many bytes and pixels a side. */
|
|
282
|
+
mediaGifBytes: 1024 * 1024,
|
|
283
|
+
mediaGifSide: 512,
|
|
284
|
+
/** Media an account uploads in the dashboard (or a repo owner's deploys add to game pages) in any 24 hours. */
|
|
285
|
+
mediaUploadsPerDay: 200,
|
|
286
|
+
mediaBytesPerDay: 500 * 1024 * 1024,
|
|
287
|
+
badgesPerGame: 50,
|
|
288
|
+
/** The menu's "How to play": up to this many controls (each with a few keys and a short action), and a few lines of help. */
|
|
289
|
+
controlHints: 12,
|
|
290
|
+
controlHintKeys: 4,
|
|
291
|
+
controlKeyLength: 12,
|
|
292
|
+
controlActionLength: 40,
|
|
293
|
+
helpLength: 400,
|
|
58
294
|
badgeNameLength: 50,
|
|
59
295
|
badgeDescriptionLength: 200,
|
|
60
296
|
leaderboardsPerGame: 25,
|
|
@@ -62,20 +298,41 @@ var LIMITS = {
|
|
|
62
298
|
gamesPerOwner: 50,
|
|
63
299
|
/** New games per creator in any rolling 24-hour period. Redeploys do not count. */
|
|
64
300
|
gamesCreatedPerCreatorPerDay: 3,
|
|
301
|
+
/** An account made with just a username (no email, Google or GitHub) can publish a new game by upload once it is this old. */
|
|
302
|
+
uploadAccountAgeMs: 7 * 24 * 60 * 6e4,
|
|
65
303
|
deploysPerOwnerPerDay: 100,
|
|
66
304
|
/** Finished versions retained per game, including its live version. */
|
|
67
305
|
deploymentsKeptPerGame: 10,
|
|
68
306
|
deployBytesPerOwnerPerDay: 2 * 1024 * 1024 * 1024,
|
|
307
|
+
/** Files listed by all of a repo owner's deploys in any 24 hours (each is a record to keep and clean up). */
|
|
308
|
+
deployEntriesPerOwnerPerDay: 1e5,
|
|
69
309
|
bioLength: 500,
|
|
310
|
+
/** Settings > Download my data: rows from any one table, the characters of saves, and how often an account may ask. */
|
|
311
|
+
exportRows: 2e3,
|
|
312
|
+
exportSaveChars: 4 * 1024 * 1024,
|
|
313
|
+
exportEveryMs: 60 * 6e4,
|
|
70
314
|
friendsMax: 200,
|
|
315
|
+
/** Requests one player has sent that are still waiting. */
|
|
71
316
|
friendRequestsMax: 100,
|
|
317
|
+
/** Requests waiting for one player: more wait until they answer some. */
|
|
318
|
+
friendRequestsIncomingMax: 50,
|
|
319
|
+
/** New friend requests an account sends in 24 hours (asking again after cancelling counts again)... */
|
|
320
|
+
friendRequestsPerDay: 20,
|
|
321
|
+
/** ...and all accounts under a week old on one network (IPv4 address, or IPv6 /48) together: a bot farm's accounts share one, a school's established ones are not counted. */
|
|
322
|
+
friendRequestsPerNetworkPerDay: 120,
|
|
323
|
+
/** Friend requests that reach a player's devices (push) in a day, at most one every 10 minutes. */
|
|
324
|
+
friendRequestPushesPerDay: 5,
|
|
72
325
|
passwordMinLength: 8,
|
|
73
326
|
passwordMaxLength: 72,
|
|
74
327
|
/** Account creation attempts, with or without email, per network (IP, or IPv6 /64) in 24 hours. */
|
|
75
328
|
signupsPerNetworkPerDay: 100,
|
|
329
|
+
/** The same per wide network (an IPv6 /48); an IPv4 address with a proof of work may go on to this too. */
|
|
330
|
+
signupsPerWidePerDay: 300,
|
|
76
331
|
/** New guest players per network per day (each one can hold saves). */
|
|
77
332
|
guestsPerNetworkPerDay: 300,
|
|
78
|
-
/**
|
|
333
|
+
/** The same per wide network (an IPv6 /48); an IPv4 address with a proof of work may go on to this too. */
|
|
334
|
+
guestsPerWidePerDay: 1500,
|
|
335
|
+
/** Visits a game gets per network (IPv4 address, or IPv6 /48) per day: a script can't pump the count. */
|
|
79
336
|
visitsPerNetworkPerDay: 10,
|
|
80
337
|
/** Seconds of visible play before a play counts as a visit. */
|
|
81
338
|
visitSeconds: 10,
|
|
@@ -95,7 +352,11 @@ var LIMITS = {
|
|
|
95
352
|
conversationsListed: 50,
|
|
96
353
|
notificationsListed: 50,
|
|
97
354
|
/** Friends one game invite can go to. */
|
|
98
|
-
invitesPerRequest: 10
|
|
355
|
+
invitesPerRequest: 10,
|
|
356
|
+
/** How long an invite can be joined: it's for now. (It's over sooner when the sender leaves the server it points at, or you're already there.) */
|
|
357
|
+
inviteLiveMs: 30 * 6e4,
|
|
358
|
+
/** How long an invite to a party can be answered. */
|
|
359
|
+
partyInviteMs: 60 * 6e4
|
|
99
360
|
};
|
|
100
361
|
var CONTENT_TYPES = {
|
|
101
362
|
html: "text/html; charset=utf-8",
|
|
@@ -163,7 +424,7 @@ function contentTypeFor(path) {
|
|
|
163
424
|
return CONTENT_TYPES[extensionOf(path)];
|
|
164
425
|
}
|
|
165
426
|
function isValidDeployPath(path) {
|
|
166
|
-
if (path.length === 0 || path.length >
|
|
427
|
+
if (path.length === 0 || path.length > LIMITS.deployPathLength) return false;
|
|
167
428
|
if (path.startsWith("/") || path.includes("\\")) return false;
|
|
168
429
|
if (/[\u0000-\u001f\u007f]/.test(path)) return false;
|
|
169
430
|
for (const segment of path.split("/")) {
|
|
@@ -173,10 +434,213 @@ function isValidDeployPath(path) {
|
|
|
173
434
|
}
|
|
174
435
|
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
175
436
|
var RESERVED_SLUGS = /* @__PURE__ */ new Set(["www", "api", "play", "admin", "static", "assets", "docs", "status"]);
|
|
437
|
+
var NEW_GAME_RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
438
|
+
"star-party",
|
|
439
|
+
"login",
|
|
440
|
+
"log-in",
|
|
441
|
+
"logon",
|
|
442
|
+
"logout",
|
|
443
|
+
"signin",
|
|
444
|
+
"sign-in",
|
|
445
|
+
"signup",
|
|
446
|
+
"sign-up",
|
|
447
|
+
"register",
|
|
448
|
+
"auth",
|
|
449
|
+
"oauth",
|
|
450
|
+
"sso",
|
|
451
|
+
"verify",
|
|
452
|
+
"verified",
|
|
453
|
+
"verification",
|
|
454
|
+
"support",
|
|
455
|
+
"help",
|
|
456
|
+
"helpdesk",
|
|
457
|
+
"contact",
|
|
458
|
+
"official",
|
|
459
|
+
"staff",
|
|
460
|
+
"team",
|
|
461
|
+
"moderator",
|
|
462
|
+
"moderators",
|
|
463
|
+
"mod",
|
|
464
|
+
"mods",
|
|
465
|
+
"security",
|
|
466
|
+
"account",
|
|
467
|
+
"accounts",
|
|
468
|
+
"password",
|
|
469
|
+
"passwords",
|
|
470
|
+
"reset",
|
|
471
|
+
"billing",
|
|
472
|
+
"payment",
|
|
473
|
+
"payments",
|
|
474
|
+
"robux",
|
|
475
|
+
"vbucks",
|
|
476
|
+
"v-bucks",
|
|
477
|
+
"nitro",
|
|
478
|
+
"gift",
|
|
479
|
+
"gifts",
|
|
480
|
+
"giftcard",
|
|
481
|
+
"free",
|
|
482
|
+
"settings",
|
|
483
|
+
"dashboard",
|
|
484
|
+
"discover",
|
|
485
|
+
"games",
|
|
486
|
+
"users",
|
|
487
|
+
"friends",
|
|
488
|
+
"notifications",
|
|
489
|
+
"terms",
|
|
490
|
+
"privacy",
|
|
491
|
+
"changelog",
|
|
492
|
+
"create",
|
|
493
|
+
"guide",
|
|
494
|
+
"about",
|
|
495
|
+
"blog",
|
|
496
|
+
"news",
|
|
497
|
+
"mail",
|
|
498
|
+
"email"
|
|
499
|
+
]);
|
|
500
|
+
var NEW_GAME_RESERVED_PREFIXES = ["onceworlds", "admin", "free-"];
|
|
501
|
+
var SLUG_AUTH_WORDS = /* @__PURE__ */ new Set(["login", "logon", "signin", "signon", "signup", "verify", "verification", "password", "passwords", "billing"]);
|
|
502
|
+
var BRANDS = ["onceworlds", "roblox", "robux", "vbucks", "fortnite", "minecraft", "xbox", "playstation", "nintendo"];
|
|
503
|
+
var LOOKALIKES = {
|
|
504
|
+
"0": "o",
|
|
505
|
+
"3": "e",
|
|
506
|
+
"4": "a",
|
|
507
|
+
"5": "s",
|
|
508
|
+
"7": "t",
|
|
509
|
+
"8": "b",
|
|
510
|
+
"@": "a",
|
|
511
|
+
"$": "s",
|
|
512
|
+
"|": "l",
|
|
513
|
+
"!": "i",
|
|
514
|
+
"\u0430": "a",
|
|
515
|
+
"\u0432": "b",
|
|
516
|
+
"\u0435": "e",
|
|
517
|
+
"\u043A": "k",
|
|
518
|
+
"\u043C": "m",
|
|
519
|
+
"\u043D": "h",
|
|
520
|
+
"\u043E": "o",
|
|
521
|
+
"\u0440": "p",
|
|
522
|
+
"\u0441": "c",
|
|
523
|
+
"\u0442": "t",
|
|
524
|
+
"\u0443": "y",
|
|
525
|
+
"\u0445": "x",
|
|
526
|
+
"\u0455": "s",
|
|
527
|
+
"\u0456": "i",
|
|
528
|
+
"\u0458": "j",
|
|
529
|
+
"\u04CF": "l",
|
|
530
|
+
"\u0501": "d",
|
|
531
|
+
"\u051D": "w",
|
|
532
|
+
"\u03B1": "a",
|
|
533
|
+
"\u03B2": "b",
|
|
534
|
+
"\u03B5": "e",
|
|
535
|
+
"\u03B9": "i",
|
|
536
|
+
"\u03BA": "k",
|
|
537
|
+
"\u03BD": "v",
|
|
538
|
+
"\u03BF": "o",
|
|
539
|
+
"\u03C1": "p",
|
|
540
|
+
"\u03C4": "t",
|
|
541
|
+
"\u03C5": "u",
|
|
542
|
+
"\u03C7": "x",
|
|
543
|
+
"\u03C9": "w"
|
|
544
|
+
};
|
|
176
545
|
function isValidSlug(slug) {
|
|
177
546
|
return SLUG_RE.test(slug) && !slug.includes("--") && !RESERVED_SLUGS.has(slug);
|
|
178
547
|
}
|
|
548
|
+
function isReservedSlug(slug, newGame = true) {
|
|
549
|
+
if (RESERVED_SLUGS.has(slug)) return true;
|
|
550
|
+
if (!newGame) return false;
|
|
551
|
+
if (NEW_GAME_RESERVED_SLUGS.has(slug) || NEW_GAME_RESERVED_PREFIXES.some((prefix) => slug.startsWith(prefix))) return true;
|
|
552
|
+
if (!SLUG_RE.test(slug)) return false;
|
|
553
|
+
const parts = slug.split("-");
|
|
554
|
+
if (parts.some((part, i) => SLUG_AUTH_WORDS.has(part) || SLUG_AUTH_WORDS.has(part + (parts[i + 1] ?? "")))) return true;
|
|
555
|
+
return hasBrand(slug);
|
|
556
|
+
}
|
|
557
|
+
function foldedWords(text) {
|
|
558
|
+
const base = text.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "");
|
|
559
|
+
const variants = [];
|
|
560
|
+
for (const one of ["l", "i"]) {
|
|
561
|
+
const mapped = [...base].map((c) => c === "1" ? one : LOOKALIKES[c] ?? c);
|
|
562
|
+
const words = mapped.map((c) => /[a-z]/.test(c) ? c : /[0-9]/.test(c) ? "" : " ").join("").split(" ").filter(Boolean);
|
|
563
|
+
variants.push(words, words.map((w) => w.replace(/vv/g, "w")));
|
|
564
|
+
}
|
|
565
|
+
return variants;
|
|
566
|
+
}
|
|
567
|
+
function hasBrand(text) {
|
|
568
|
+
for (const words of foldedWords(text)) {
|
|
569
|
+
if (words.some((word) => BRANDS.some((brand) => word.includes(brand)))) return true;
|
|
570
|
+
for (let i = 0; i < words.length; i++) {
|
|
571
|
+
let joined = words[i];
|
|
572
|
+
for (let j = i + 1; j < words.length && joined.length < 16; j++) {
|
|
573
|
+
joined += words[j];
|
|
574
|
+
if (BRANDS.includes(joined)) return true;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
179
580
|
var DEFAULT_PRODUCTION_BRANCH = "main";
|
|
581
|
+
function deployWorkflow() {
|
|
582
|
+
return `name: Deploy to Onceworlds
|
|
583
|
+
|
|
584
|
+
# Branches only: tags don't deploy.
|
|
585
|
+
on:
|
|
586
|
+
push:
|
|
587
|
+
branches: ['**']
|
|
588
|
+
|
|
589
|
+
# A newer push to the same branch replaces a deploy still running.
|
|
590
|
+
concurrency:
|
|
591
|
+
group: onceworlds-\${{ github.ref }}
|
|
592
|
+
cancel-in-progress: true
|
|
593
|
+
|
|
594
|
+
permissions:
|
|
595
|
+
contents: read
|
|
596
|
+
id-token: write # lets the Onceworlds CLI prove which repo is deploying; no API keys needed
|
|
597
|
+
|
|
598
|
+
jobs:
|
|
599
|
+
deploy:
|
|
600
|
+
runs-on: ubuntu-latest
|
|
601
|
+
steps:
|
|
602
|
+
# The build never needs the repo's GitHub token, so it isn't left in .git/config.
|
|
603
|
+
- uses: actions/checkout@v5
|
|
604
|
+
with:
|
|
605
|
+
persist-credentials: false
|
|
606
|
+
|
|
607
|
+
- uses: actions/setup-node@v5
|
|
608
|
+
with:
|
|
609
|
+
node-version: 22
|
|
610
|
+
|
|
611
|
+
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
|
612
|
+
if: hashFiles('bun.lock', 'bun.lockb') != ''
|
|
613
|
+
|
|
614
|
+
# Only if package.json has a build script, with the package manager its lockfile belongs to.
|
|
615
|
+
- name: Build
|
|
616
|
+
run: |
|
|
617
|
+
if [ ! -f package.json ] || [ "$(npm pkg get scripts.build)" = "{}" ]; then
|
|
618
|
+
exit 0
|
|
619
|
+
elif [ -f pnpm-lock.yaml ]; then
|
|
620
|
+
corepack enable
|
|
621
|
+
pnpm install --frozen-lockfile
|
|
622
|
+
pnpm run build
|
|
623
|
+
elif [ -f yarn.lock ]; then
|
|
624
|
+
corepack enable
|
|
625
|
+
yarn install
|
|
626
|
+
yarn run build
|
|
627
|
+
elif [ -f bun.lock ] || [ -f bun.lockb ]; then
|
|
628
|
+
bun install
|
|
629
|
+
bun run build
|
|
630
|
+
elif [ -f package-lock.json ]; then
|
|
631
|
+
npm ci
|
|
632
|
+
npm run build
|
|
633
|
+
else
|
|
634
|
+
npm install
|
|
635
|
+
npm run build
|
|
636
|
+
fi
|
|
637
|
+
|
|
638
|
+
# Pushes to the production branch (set in the dashboard or onceworlds.json,
|
|
639
|
+
# default "main") go live; other branches get a preview URL in the job log.
|
|
640
|
+
- name: Deploy
|
|
641
|
+
run: npx --yes @onceworlds/cli@0 deploy
|
|
642
|
+
`;
|
|
643
|
+
}
|
|
180
644
|
var BADGE_KEY_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
|
181
645
|
var GENRES = [
|
|
182
646
|
"Action",
|
|
@@ -196,6 +660,30 @@ var GENRES = [
|
|
|
196
660
|
"Party",
|
|
197
661
|
"Other"
|
|
198
662
|
];
|
|
663
|
+
var tidy = (text) => text.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
|
664
|
+
function checkGuide(controls, help) {
|
|
665
|
+
const hints = [];
|
|
666
|
+
if (controls !== void 0 && controls !== null) {
|
|
667
|
+
if (!Array.isArray(controls)) throw new Error('"controls" must be a list, like [{ "keys": ["W", "A", "S", "D"], "action": "Move" }]');
|
|
668
|
+
if (controls.length > LIMITS.controlHints) throw new Error(`"controls" can have at most ${LIMITS.controlHints} items`);
|
|
669
|
+
controls.forEach((item, i) => {
|
|
670
|
+
const { keys, action } = item ?? {};
|
|
671
|
+
const name = `controls[${i}]`;
|
|
672
|
+
if (!Array.isArray(keys) || keys.length === 0 || keys.length > LIMITS.controlHintKeys) {
|
|
673
|
+
throw new Error(`${name}.keys must be 1-${LIMITS.controlHintKeys} keys or buttons, like ["Space"]`);
|
|
674
|
+
}
|
|
675
|
+
const cleaned = keys.map((k) => typeof k === "string" ? tidy(k) : "");
|
|
676
|
+
if (cleaned.some((k) => !k || k.length > LIMITS.controlKeyLength)) throw new Error(`${name}.keys are short names (at most ${LIMITS.controlKeyLength} characters), like "Space" or "Left click"`);
|
|
677
|
+
const text2 = typeof action === "string" ? tidy(action) : "";
|
|
678
|
+
if (!text2 || text2.length > LIMITS.controlActionLength) throw new Error(`${name}.action is what the keys do, in at most ${LIMITS.controlActionLength} characters`);
|
|
679
|
+
hints.push({ keys: cleaned, action: text2 });
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
if (help !== void 0 && help !== null && typeof help !== "string") throw new Error('"help" must be text');
|
|
683
|
+
const text = typeof help === "string" ? help.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, " ").replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim() : "";
|
|
684
|
+
if (text.length > LIMITS.helpLength) throw new Error(`"help" can be at most ${LIMITS.helpLength} characters`);
|
|
685
|
+
return { controls: hints, help: text };
|
|
686
|
+
}
|
|
199
687
|
var MAX_IMAGE_SIDE = 4096;
|
|
200
688
|
function imageInfo(bytes) {
|
|
201
689
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
@@ -253,16 +741,149 @@ function imageSizeProblem(slot, width, height) {
|
|
|
253
741
|
return null;
|
|
254
742
|
}
|
|
255
743
|
|
|
744
|
+
// src/init.ts
|
|
745
|
+
var OUTPUT_DIRS = ["dist", "build", "out", "public", "web", "www", "export"];
|
|
746
|
+
var WORKFLOW_PATH = ".github/workflows/onceworlds.yml";
|
|
747
|
+
async function init(dir, options) {
|
|
748
|
+
const log = options.log ?? console.log;
|
|
749
|
+
const { force } = options;
|
|
750
|
+
const at = (path) => join(dir, path);
|
|
751
|
+
const read = (path) => readFile(at(path), "utf8").catch(() => null);
|
|
752
|
+
const exists = (path) => stat(at(path)).then(() => true, () => false);
|
|
753
|
+
const lines = [];
|
|
754
|
+
let kept = false;
|
|
755
|
+
const put = async (path, content, label = path) => {
|
|
756
|
+
try {
|
|
757
|
+
await mkdir(dirname(at(path)), { recursive: true });
|
|
758
|
+
await writeFile(at(path), content, { flag: force ? "w" : "wx" });
|
|
759
|
+
lines.push(` wrote ${label}`);
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (err.code !== "EEXIST") throw err;
|
|
762
|
+
lines.push(` kept ${path} (it exists already)`);
|
|
763
|
+
kept = true;
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
const append = async (path, existing, line, label) => {
|
|
767
|
+
await writeFile(at(path), `${existing.trimEnd()}
|
|
768
|
+
|
|
769
|
+
${line}
|
|
770
|
+
`);
|
|
771
|
+
lines.push(` wrote ${label}`);
|
|
772
|
+
};
|
|
773
|
+
const pkg = await readPackage(dir);
|
|
774
|
+
const source = typeof pkg?.name === "string" && pkg.name.replace(/^@[^/]+\//, "") || basename(dir);
|
|
775
|
+
const fromName = slugFrom(source);
|
|
776
|
+
const slug = fromName ?? `game-${randomBytes(3).toString("hex")}`;
|
|
777
|
+
const found = await findDir(pkg, exists);
|
|
778
|
+
const branch = defaultBranch(dir);
|
|
779
|
+
const config = { slug, name: titleFrom(source), description: "", dir: found.dir, ...branch ? { productionBranch: branch } : {} };
|
|
780
|
+
await put("onceworlds.json", `${JSON.stringify(config, null, 2)}
|
|
781
|
+
`, `onceworlds.json (link "${slug}", publishing "${found.dir}")`);
|
|
782
|
+
const present = await findWorkflow(dir);
|
|
783
|
+
if (present && present !== WORKFLOW_PATH) lines.push(` kept ${present} (it already deploys with the Onceworlds CLI)`);
|
|
784
|
+
else await put(WORKFLOW_PATH, deployWorkflow());
|
|
785
|
+
const guide = await fetchGuide(options.fetch ?? fetch, options.api, (line) => lines.push(line));
|
|
786
|
+
const agents = await read("AGENTS.md");
|
|
787
|
+
const separate = agents !== null && !/^# Onceworlds\b/.test(agents);
|
|
788
|
+
const guidePath = separate ? "ONCEWORLDS.md" : "AGENTS.md";
|
|
789
|
+
if (guide !== null) await put(guidePath, guide);
|
|
790
|
+
if (guide !== null || await exists(guidePath)) {
|
|
791
|
+
if (separate && !agents.includes("ONCEWORLDS.md")) {
|
|
792
|
+
await append("AGENTS.md", agents, "## Onceworlds\n\nThis game is published on Onceworlds. Read ONCEWORLDS.md for the platform rules and SDK.", "AGENTS.md (a pointer to ONCEWORLDS.md)");
|
|
793
|
+
}
|
|
794
|
+
const claude = await read("CLAUDE.md");
|
|
795
|
+
if (claude === null) await put("CLAUDE.md", `@${guidePath}
|
|
796
|
+
`);
|
|
797
|
+
else if (!claude.includes(guidePath)) await append("CLAUDE.md", claude, `@${guidePath}`, `CLAUDE.md (a pointer to ${guidePath})`);
|
|
798
|
+
}
|
|
799
|
+
for (const line of lines) log(line);
|
|
800
|
+
if (fromName === null) log(` note "${source}" can't be a game's link, so the link is ${slug}. Change slug in onceworlds.json.`);
|
|
801
|
+
if (found.note) log(` note ${found.note}`);
|
|
802
|
+
if (kept) log(" note --force replaces the files init writes. Your own AGENTS.md and CLAUDE.md are never replaced.");
|
|
803
|
+
log(`
|
|
804
|
+
Next:
|
|
805
|
+
1. Check onceworlds.json: "slug" is your game's link (onceworlds.com/play/<slug>) and "name" is its title.
|
|
806
|
+
2. Commit these files and push the repo to GitHub.
|
|
807
|
+
3. Open ${options.api}/dashboard/new, import the repo, and every push publishes your game.`);
|
|
808
|
+
}
|
|
809
|
+
async function readPackage(dir) {
|
|
810
|
+
try {
|
|
811
|
+
const parsed = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
812
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function slugFrom(name) {
|
|
818
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
|
|
819
|
+
return isValidSlug(slug) && !isReservedSlug(slug) ? slug : null;
|
|
820
|
+
}
|
|
821
|
+
function titleFrom(name) {
|
|
822
|
+
const title = name.replace(/[-_.]+/g, " ").trim().replace(/\s+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
823
|
+
return (title || "My Game").slice(0, LIMITS.gameNameLength);
|
|
824
|
+
}
|
|
825
|
+
async function findDir(pkg, exists) {
|
|
826
|
+
if (typeof pkg?.scripts?.build === "string") {
|
|
827
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
828
|
+
const out = deps.next ? "out" : deps["react-scripts"] || deps["@sveltejs/kit"] ? "build" : "dist";
|
|
829
|
+
return { dir: out, note: `"dir" is ${out}, where the build script is expected to write the game. Change it in onceworlds.json if yours writes elsewhere.` };
|
|
830
|
+
}
|
|
831
|
+
if (await exists("index.html")) return { dir: "." };
|
|
832
|
+
for (const name of OUTPUT_DIRS) if (await exists(`${name}/index.html`)) return { dir: name };
|
|
833
|
+
return { dir: ".", note: `No index.html here yet. Set "dir" in onceworlds.json to the folder that has your game's index.html.` };
|
|
834
|
+
}
|
|
835
|
+
function defaultBranch(dir) {
|
|
836
|
+
const git = (args) => {
|
|
837
|
+
try {
|
|
838
|
+
return execFileSync("git", args, { cwd: dir, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || null;
|
|
839
|
+
} catch {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
return git(["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"])?.replace(/^origin\//, "") ?? git(["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
844
|
+
}
|
|
845
|
+
async function findWorkflow(dir) {
|
|
846
|
+
const folder = join(dir, ".github/workflows");
|
|
847
|
+
const names = await readdir(folder).catch(() => []);
|
|
848
|
+
for (const name of names.filter((n) => /\.ya?ml$/.test(n)).slice(0, 20)) {
|
|
849
|
+
if ((await readFile(join(folder, name), "utf8").catch(() => "")).includes("@onceworlds/cli")) return `.github/workflows/${name}`;
|
|
850
|
+
}
|
|
851
|
+
return null;
|
|
852
|
+
}
|
|
853
|
+
async function fetchGuide(fetcher, api, warn) {
|
|
854
|
+
const url = `${api}/agents.md`;
|
|
855
|
+
let problem;
|
|
856
|
+
try {
|
|
857
|
+
const res = await fetcher(url, { signal: AbortSignal.timeout(1e4) });
|
|
858
|
+
const text = res.ok ? await res.text() : "";
|
|
859
|
+
if (text.trim()) return text;
|
|
860
|
+
problem = res.ok ? "it is empty" : `${res.status}`;
|
|
861
|
+
} catch (err) {
|
|
862
|
+
problem = err instanceof Error ? err.message : String(err);
|
|
863
|
+
}
|
|
864
|
+
warn(` ! Skipped AGENTS.md and CLAUDE.md: could not fetch ${url} (${problem}). Run init again when it works.`);
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
|
|
256
868
|
// src/index.ts
|
|
257
869
|
var PRODUCTION_API = "https://onceworlds.com";
|
|
258
870
|
var LOCAL_API = "http://localhost:8787";
|
|
259
871
|
var USAGE = `Usage:
|
|
872
|
+
onceworlds init [project-dir] Set a game folder up for Onceworlds: writes onceworlds.json, the GitHub workflow
|
|
873
|
+
that publishes every push, and AGENTS.md and CLAUDE.md for AI coding tools
|
|
260
874
|
onceworlds deploy [project-dir] Upload the game and publish it (to ${PRODUCTION_API})
|
|
261
875
|
onceworlds dev [project-dir] Deploy to a local platform (${LOCAL_API}), then redeploy on every change
|
|
876
|
+
onceworlds doctor [project-dir] Look for what games often ship with: no viewport tag, small text fields,
|
|
877
|
+
localStorage, dialogs, Date.now() for shared time, keys with no touch
|
|
878
|
+
controls, a missing icon, thumbnails or How to play (suggestions only)
|
|
262
879
|
|
|
263
880
|
Options:
|
|
264
881
|
--api <url> Platform URL (default: $ONCEWORLDS_API, else the ones above)
|
|
265
882
|
--token <t> Local platform's deploy token (default: "dev")
|
|
883
|
+
--players <n> dev: also print links for players 2 to n, to play a multiplayer game with yourself
|
|
884
|
+
--strict doctor: exit with an error when it finds a likely bug (not for notes)
|
|
885
|
+
--force init: replace onceworlds.json, the workflow and the guide when they exist already
|
|
886
|
+
(never your own AGENTS.md or CLAUDE.md)
|
|
266
887
|
|
|
267
888
|
Deploys to onceworlds.com run from GitHub Actions: the repo's Onceworlds
|
|
268
889
|
workflow runs this on every push, and GitHub vouches for the repo.
|
|
@@ -277,7 +898,14 @@ var QUIET_EXTENSIONS = /* @__PURE__ */ new Set(["md", "ts", "tsx", "jsx", "lock"
|
|
|
277
898
|
async function main() {
|
|
278
899
|
const { values, positionals } = parseArgs({
|
|
279
900
|
allowPositionals: true,
|
|
280
|
-
options: {
|
|
901
|
+
options: {
|
|
902
|
+
api: { type: "string" },
|
|
903
|
+
token: { type: "string" },
|
|
904
|
+
players: { type: "string" },
|
|
905
|
+
strict: { type: "boolean" },
|
|
906
|
+
force: { type: "boolean" },
|
|
907
|
+
help: { type: "boolean", short: "h" }
|
|
908
|
+
}
|
|
281
909
|
});
|
|
282
910
|
const [command, projectArg] = positionals;
|
|
283
911
|
if (values.help || !command || command === "help") {
|
|
@@ -286,10 +914,17 @@ async function main() {
|
|
|
286
914
|
}
|
|
287
915
|
const projectDir = resolve(projectArg ?? ".");
|
|
288
916
|
const api = (values.api ?? process.env.ONCEWORLDS_API ?? (command === "dev" ? LOCAL_API : PRODUCTION_API)).replace(/\/+$/, "");
|
|
289
|
-
if (command === "
|
|
917
|
+
if (command === "init") {
|
|
918
|
+
if (!(await stat2(projectDir).catch(() => null))?.isDirectory()) throw new CliError(`${projectDir} isn't a folder.`);
|
|
919
|
+
await init(projectDir, { api, force: values.force === true });
|
|
920
|
+
} else if (command === "deploy") {
|
|
290
921
|
await deploy(projectDir, api, values.token);
|
|
291
922
|
} else if (command === "dev") {
|
|
292
|
-
|
|
923
|
+
const players = Number(values.players ?? 1);
|
|
924
|
+
if (!Number.isInteger(players) || players < 1 || players > 9) throw new CliError("--players is a number from 1 to 9.");
|
|
925
|
+
await dev(projectDir, api, values.token, players);
|
|
926
|
+
} else if (command === "doctor") {
|
|
927
|
+
await doctor(projectDir, values.strict === true);
|
|
293
928
|
} else {
|
|
294
929
|
throw new CliError(`Unknown command "${command}".
|
|
295
930
|
|
|
@@ -307,6 +942,12 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
307
942
|
if (!files.some((f) => f.path === "index.html")) {
|
|
308
943
|
throw new CliError(`No index.html in ${root}. Set "dir" in onceworlds.json to the folder that contains it.`);
|
|
309
944
|
}
|
|
945
|
+
if (!quiet) {
|
|
946
|
+
const findings = [...checkGame(await readSources(files)), ...checkListing(config)];
|
|
947
|
+
for (const f of findings.filter((x) => x.level === "warn")) console.warn(` ! ${f.file}:${f.line} ${f.message}`);
|
|
948
|
+
const notes = findings.filter((x) => x.level === "note").length;
|
|
949
|
+
if (notes > 0) console.log(` ${notes} suggestion${notes === 1 ? "" : "s"} for this game: run "npx @onceworlds/cli doctor" to see them.`);
|
|
950
|
+
}
|
|
310
951
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
311
952
|
if (total > LIMITS.deployTotalBytes) throw new CliError(`Game is ${formatBytes(total)}; the limit is ${formatBytes(LIMITS.deployTotalBytes)}.`);
|
|
312
953
|
const { store, images } = await loadStore(projectDir, config);
|
|
@@ -329,7 +970,7 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
329
970
|
console.log(` \u2191 uploading ${uploads.length} changed file(s), ${formatBytes(uploads.reduce((s, f) => s + f.size, 0))}`);
|
|
330
971
|
}
|
|
331
972
|
await runPool(uploads, 6, async (file) => {
|
|
332
|
-
const body = await
|
|
973
|
+
const body = await readFile2(file.abs);
|
|
333
974
|
await send("PUT", `${api}/api/deploy/${start.deploymentId}/blobs/${file.hash}`, start.uploadToken, body);
|
|
334
975
|
});
|
|
335
976
|
const result = await send("POST", `${api}/api/deploy/${start.deploymentId}/finalize`, start.uploadToken, {});
|
|
@@ -341,15 +982,16 @@ async function deploy(projectDir, api, tokenArg, quiet = false) {
|
|
|
341
982
|
}
|
|
342
983
|
return result;
|
|
343
984
|
}
|
|
344
|
-
async function dev(projectDir, api, tokenArg) {
|
|
985
|
+
async function dev(projectDir, api, tokenArg, players = 1) {
|
|
345
986
|
const config = await loadConfig(projectDir);
|
|
346
987
|
const root = resolve(projectDir, config.dir ?? ".");
|
|
347
988
|
const first = await deploy(projectDir, api, tokenArg);
|
|
348
989
|
console.log(`
|
|
349
|
-
Play (reloads on every change): ${first.playUrl}?watch
|
|
350
|
-
|
|
990
|
+
Play (reloads on every change): ${first.playUrl}?watch`);
|
|
991
|
+
for (let n = 2; n <= players; n++) console.log(` Player ${n}: ${first.playUrl}?player=${n}&watch`);
|
|
992
|
+
console.log(` Watching ${root}. Press Ctrl+C to stop.
|
|
351
993
|
`);
|
|
352
|
-
const projectSignature = async () => signature((await collectFiles(root)).files) + await
|
|
994
|
+
const projectSignature = async () => signature((await collectFiles(root)).files) + await readFile2(join2(projectDir, "onceworlds.json"), "utf8").catch(() => "");
|
|
353
995
|
let lastSignature = await projectSignature();
|
|
354
996
|
let timer;
|
|
355
997
|
let running = false;
|
|
@@ -388,11 +1030,36 @@ async function dev(projectDir, api, tokenArg) {
|
|
|
388
1030
|
await new Promise(() => {
|
|
389
1031
|
});
|
|
390
1032
|
}
|
|
1033
|
+
async function readSources(files) {
|
|
1034
|
+
const sources = [];
|
|
1035
|
+
for (const file of files) {
|
|
1036
|
+
if (!/\.(html?|m?js|css)$/i.test(file.path) || file.size > 15e5) continue;
|
|
1037
|
+
sources.push({ path: file.path, text: await readFile2(file.abs, "utf8") });
|
|
1038
|
+
}
|
|
1039
|
+
return sources;
|
|
1040
|
+
}
|
|
1041
|
+
async function doctor(projectDir, strict) {
|
|
1042
|
+
const config = await loadConfig(projectDir);
|
|
1043
|
+
const root = resolve(projectDir, config.dir ?? ".");
|
|
1044
|
+
const { files } = await collectFiles(root);
|
|
1045
|
+
const sources = await readSources(files);
|
|
1046
|
+
const findings = [...checkGame(sources), ...checkListing(config)];
|
|
1047
|
+
console.log(`Checked ${sources.length} file${sources.length === 1 ? "" : "s"} in ${root}.`);
|
|
1048
|
+
if (findings.length === 0) {
|
|
1049
|
+
console.log("Nothing to suggest.");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
for (const f of findings) console.log(` ${f.level === "warn" ? "!" : "\xB7"} ${f.file}:${f.line} ${f.message}`);
|
|
1053
|
+
const warnings = findings.filter((f) => f.level === "warn").length;
|
|
1054
|
+
console.log(`
|
|
1055
|
+
${warnings} likely problem${warnings === 1 ? "" : "s"}, ${findings.length - warnings} note${findings.length - warnings === 1 ? "" : "s"}. Nothing here stops a deploy.`);
|
|
1056
|
+
if (strict && warnings > 0) process.exitCode = 1;
|
|
1057
|
+
}
|
|
391
1058
|
async function loadConfig(projectDir) {
|
|
392
|
-
const file =
|
|
1059
|
+
const file = join2(projectDir, "onceworlds.json");
|
|
393
1060
|
let config;
|
|
394
1061
|
try {
|
|
395
|
-
config = JSON.parse(await
|
|
1062
|
+
config = JSON.parse(await readFile2(file, "utf8"));
|
|
396
1063
|
} catch (err) {
|
|
397
1064
|
const missing = err.code === "ENOENT";
|
|
398
1065
|
throw new CliError(missing ? `No onceworlds.json in ${projectDir}.
|
|
@@ -413,7 +1080,7 @@ async function loadStore(projectDir, config) {
|
|
|
413
1080
|
if (!abs.startsWith(projectDir + sep)) throw new CliError(`"${field}" in onceworlds.json (${value}) must be inside the project folder.`);
|
|
414
1081
|
let data;
|
|
415
1082
|
try {
|
|
416
|
-
data = await
|
|
1083
|
+
data = await readFile2(abs);
|
|
417
1084
|
} catch {
|
|
418
1085
|
throw new CliError(`"${field}" in onceworlds.json: ${value} doesn't exist.`);
|
|
419
1086
|
}
|
|
@@ -460,7 +1127,16 @@ async function loadStore(projectDir, config) {
|
|
|
460
1127
|
const badgeIcon = b.icon === void 0 ? null : await image(b.icon, "badge", `${field}.icon`);
|
|
461
1128
|
badges.push({ key, name, description, icon: badgeIcon });
|
|
462
1129
|
}
|
|
463
|
-
|
|
1130
|
+
let guide;
|
|
1131
|
+
try {
|
|
1132
|
+
guide = checkGuide(config.controls, config.help);
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
throw new CliError(`${err.message} in onceworlds.json.`);
|
|
1135
|
+
}
|
|
1136
|
+
const store = { genre, icon, thumbnails, badges };
|
|
1137
|
+
if (guide.controls.length > 0) store.controls = guide.controls;
|
|
1138
|
+
if (guide.help) store.help = guide.help;
|
|
1139
|
+
return { store, images: [...images.values()] };
|
|
464
1140
|
}
|
|
465
1141
|
async function collectFiles(root) {
|
|
466
1142
|
const files = [];
|
|
@@ -468,13 +1144,13 @@ async function collectFiles(root) {
|
|
|
468
1144
|
async function walk(dir, prefix) {
|
|
469
1145
|
let entries;
|
|
470
1146
|
try {
|
|
471
|
-
entries = await
|
|
1147
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
472
1148
|
} catch {
|
|
473
1149
|
throw new CliError(`Cannot read ${dir}. Check "dir" in onceworlds.json.`);
|
|
474
1150
|
}
|
|
475
1151
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
476
1152
|
if (entry.name.startsWith(".") || QUIET_SKIPS.has(entry.name)) continue;
|
|
477
|
-
const abs =
|
|
1153
|
+
const abs = join2(dir, entry.name);
|
|
478
1154
|
const path = prefix + entry.name;
|
|
479
1155
|
if (entry.isDirectory()) {
|
|
480
1156
|
await walk(abs, `${path}/`);
|
|
@@ -483,7 +1159,7 @@ async function collectFiles(root) {
|
|
|
483
1159
|
if (!QUIET_EXTENSIONS.has(extensionOf(path))) skipped.push(path);
|
|
484
1160
|
continue;
|
|
485
1161
|
}
|
|
486
|
-
const data = await
|
|
1162
|
+
const data = await readFile2(abs);
|
|
487
1163
|
if (data.byteLength > LIMITS.deployFileBytes) {
|
|
488
1164
|
throw new CliError(`${path} is ${formatBytes(data.byteLength)}; files can be at most ${formatBytes(LIMITS.deployFileBytes)}.`);
|
|
489
1165
|
}
|
|
@@ -501,7 +1177,7 @@ function signature(files) {
|
|
|
501
1177
|
function gitInfo(cwd) {
|
|
502
1178
|
const run = (args) => {
|
|
503
1179
|
try {
|
|
504
|
-
return
|
|
1180
|
+
return execFileSync2("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || void 0;
|
|
505
1181
|
} catch {
|
|
506
1182
|
return void 0;
|
|
507
1183
|
}
|