@uzuhq/code-cli 0.5.6 → 0.5.8
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/dist/cli.js +2484 -289
- package/dist/dev-server/sdk-server-shim.js +21 -0
- package/dist/harness/client-entry.js +2779 -74
- package/package.json +5 -4
- package/dist/auth/browser.js +0 -22
- package/dist/auth/config.js +0 -48
- package/dist/auth/env.js +0 -42
- package/dist/auth/jwt.js +0 -27
- package/dist/auth/login-flow.js +0 -44
- package/dist/auth/loopback.js +0 -94
- package/dist/auth/pkce.js +0 -11
- package/dist/auth/publish-token.js +0 -74
- package/dist/auth/token-cache.js +0 -70
- package/dist/auth/uzu-auth.js +0 -94
- package/dist/build-server-logic.js +0 -28
- package/dist/cf-images-upload.js +0 -52
- package/dist/create-2d-game.js +0 -51
- package/dist/dev-server/game-room.js +0 -630
- package/dist/dev-server/game-types.js +0 -11
- package/dist/dev-server/json-patch.js +0 -114
- package/dist/dev-server/load-logic.js +0 -70
- package/dist/dev-server/random.js +0 -35
- package/dist/dev-server/relay-room.js +0 -86
- package/dist/dev-server/server.js +0 -367
- package/dist/dev-server/sync-room.js +0 -270
- package/dist/dev.js +0 -235
- package/dist/harness/admin-client.js +0 -215
- package/dist/harness/dev-button.js +0 -258
- package/dist/harness/mount.js +0 -786
- package/dist/harness/page.js +0 -46
- package/dist/r2-upload.js +0 -93
- package/dist/rest-register.js +0 -52
- package/dist/sdk-version.js +0 -41
- package/dist/upload-session.js +0 -71
package/dist/cli.js
CHANGED
|
@@ -1,326 +1,2521 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import { execSync } from "child_process";
|
|
6
|
+
import { readFileSync as readFileSync6, existsSync as existsSync4, unlinkSync, createWriteStream } from "fs";
|
|
7
|
+
import { dirname as dirname5, resolve as resolve5 } from "path";
|
|
8
|
+
import { ZipArchive } from "archiver";
|
|
9
|
+
import { fileURLToPath as fileURLToPath4, pathToFileURL as pathToFileURL2 } from "url";
|
|
10
|
+
|
|
11
|
+
// src/r2-upload.ts
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { readdir } from "fs/promises";
|
|
14
|
+
import mime from "mime-types";
|
|
15
|
+
import { basename, join as join2, relative, sep } from "path";
|
|
16
|
+
|
|
17
|
+
// src/auth/env.ts
|
|
18
|
+
var UZU_ENVS = ["dev", "stg", "prd"];
|
|
19
|
+
var DEFAULT_ENV = "dev";
|
|
20
|
+
var ENV_HOSTS = {
|
|
21
|
+
prd: {
|
|
22
|
+
authHost: "auth.uzu-app.com",
|
|
23
|
+
graphHost: "prd.graph.backend.app.uzu.one",
|
|
24
|
+
studioHost: "studio.uzu-app.com"
|
|
25
|
+
},
|
|
26
|
+
stg: {
|
|
27
|
+
authHost: "stg.auth.uzu-app.com",
|
|
28
|
+
graphHost: "stg.graph.backend.app.uzu.one",
|
|
29
|
+
studioHost: "stg.studio.uzu-app.com"
|
|
30
|
+
},
|
|
31
|
+
dev: {
|
|
32
|
+
authHost: "dev.auth.uzu-app.com",
|
|
33
|
+
graphHost: "dev.graph.backend.app.uzu.one",
|
|
34
|
+
studioHost: "dev.studio.uzu-app.com"
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var isUzuEnv = (v) => UZU_ENVS.includes(v);
|
|
38
|
+
var resolveEnv = (raw) => {
|
|
39
|
+
if (raw === void 0 || raw === "") return DEFAULT_ENV;
|
|
40
|
+
if (!isUzuEnv(raw)) {
|
|
41
|
+
throw new Error(`\u4E0D\u6B63\u306A env: ${raw} (dev | stg | prd \u306E\u3044\u305A\u308C\u304B\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044)`);
|
|
42
|
+
}
|
|
43
|
+
return raw;
|
|
44
|
+
};
|
|
45
|
+
var authBaseURL = (env) => `https://${ENV_HOSTS[env].authHost}`;
|
|
46
|
+
var graphBaseURL = (env) => `https://${ENV_HOSTS[env].graphHost}`;
|
|
47
|
+
var studioHost = (env) => ENV_HOSTS[env].studioHost;
|
|
48
|
+
|
|
49
|
+
// src/auth/config.ts
|
|
50
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
51
|
+
import { homedir } from "node:os";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
var CONFIG_DIR = join(homedir(), ".config", "uzu-cli");
|
|
54
|
+
var CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
55
|
+
var readRaw = async () => {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(await readFile(CREDENTIALS_FILE, "utf8"));
|
|
58
|
+
if (parsed !== null && typeof parsed === "object" && "envs" in parsed) {
|
|
59
|
+
return parsed;
|
|
53
60
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
return { envs: {} };
|
|
64
|
+
};
|
|
65
|
+
var write = async (creds) => {
|
|
66
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
67
|
+
await writeFile(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 384 });
|
|
68
|
+
};
|
|
69
|
+
var getCredentials = async (env) => {
|
|
70
|
+
const creds = await readRaw();
|
|
71
|
+
return creds.envs[env] ?? null;
|
|
72
|
+
};
|
|
73
|
+
var saveCredentials = async (env, envCreds) => {
|
|
74
|
+
const creds = await readRaw();
|
|
75
|
+
creds.envs[env] = envCreds;
|
|
76
|
+
await write(creds);
|
|
77
|
+
};
|
|
78
|
+
var clearCredentials = async (env) => {
|
|
79
|
+
const creds = await readRaw();
|
|
80
|
+
if (creds.envs[env] === void 0) return false;
|
|
81
|
+
delete creds.envs[env];
|
|
82
|
+
await write(creds);
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
var credentialsPath = () => CREDENTIALS_FILE;
|
|
86
|
+
|
|
87
|
+
// src/auth/publish-token.ts
|
|
88
|
+
var PUBLISH_TOKEN_ENV = "UZU_PUBLISH_TOKEN";
|
|
89
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
90
|
+
var publishTokenFromEnv = () => {
|
|
91
|
+
const raw = process.env[PUBLISH_TOKEN_ENV];
|
|
92
|
+
if (raw === void 0) return null;
|
|
93
|
+
const token = raw.trim();
|
|
94
|
+
return token === "" ? null : token;
|
|
95
|
+
};
|
|
96
|
+
var parseApiError = (text, status) => {
|
|
97
|
+
try {
|
|
98
|
+
const j = JSON.parse(text);
|
|
99
|
+
if (j.error) {
|
|
100
|
+
return new Error(
|
|
101
|
+
j.error_description ? `uzu auth: ${j.error} (${j.error_description})` : `uzu auth: ${j.error}`
|
|
102
|
+
);
|
|
57
103
|
}
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
return new Error(`uzu auth: status ${status}`);
|
|
107
|
+
};
|
|
108
|
+
var request = async (baseUrl, path, idToken, init) => {
|
|
109
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
110
|
+
method: init.method,
|
|
111
|
+
headers: {
|
|
112
|
+
authorization: `Bearer ${idToken}`,
|
|
113
|
+
...init.body === void 0 ? {} : { "content-type": "application/json" }
|
|
114
|
+
},
|
|
115
|
+
body: init.body === void 0 ? void 0 : JSON.stringify(init.body),
|
|
116
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
117
|
+
});
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
if (!res.ok) throw parseApiError(text, res.status);
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(text);
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error("uzu auth: invalid response");
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
var createPublishToken = (baseUrl, idToken, name) => request(baseUrl, "/api/publish-tokens", idToken, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: { name }
|
|
58
129
|
});
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
130
|
+
var listPublishTokens = async (baseUrl, idToken) => {
|
|
131
|
+
const { tokens } = await request(
|
|
132
|
+
baseUrl,
|
|
133
|
+
"/api/publish-tokens",
|
|
134
|
+
idToken,
|
|
135
|
+
{ method: "GET" }
|
|
136
|
+
);
|
|
137
|
+
return tokens;
|
|
138
|
+
};
|
|
139
|
+
var revokePublishToken = async (baseUrl, idToken, id) => {
|
|
140
|
+
await request(baseUrl, `/api/publish-tokens/${encodeURIComponent(id)}`, idToken, {
|
|
141
|
+
method: "DELETE"
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/auth/uzu-auth.ts
|
|
146
|
+
var CLIENT_ID = "uzu-cli";
|
|
147
|
+
var PUBLISH_TOKEN_TYPE = "urn:uzu:params:oauth:token-type:publish-token";
|
|
148
|
+
var TOKEN_TIMEOUT_MS = 15e3;
|
|
149
|
+
var buildLoginURL = (authBaseUrl, redirectUri, codeChallenge) => {
|
|
150
|
+
const q = new URLSearchParams({
|
|
151
|
+
redirect_uri: redirectUri,
|
|
152
|
+
client_id: CLIENT_ID,
|
|
153
|
+
code_challenge: codeChallenge,
|
|
154
|
+
code_challenge_method: "S256"
|
|
155
|
+
});
|
|
156
|
+
return `${authBaseUrl}/login?${q.toString()}`;
|
|
157
|
+
};
|
|
158
|
+
var parseOAuthTokenError = (text, status) => {
|
|
159
|
+
try {
|
|
160
|
+
const j = JSON.parse(text);
|
|
161
|
+
if (j.error) {
|
|
162
|
+
return new Error(
|
|
163
|
+
j.error_description ? `uzu auth: ${j.error} (${j.error_description})` : `uzu auth: ${j.error}`
|
|
164
|
+
);
|
|
71
165
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
return new Error(`uzu auth: status ${status}`);
|
|
169
|
+
};
|
|
170
|
+
var requestToken = async (baseUrl, body) => {
|
|
171
|
+
const res = await fetch(`${baseUrl}/api/oauth/token`, {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS)
|
|
176
|
+
});
|
|
177
|
+
const text = await res.text();
|
|
178
|
+
if (!res.ok) throw parseOAuthTokenError(text, res.status);
|
|
179
|
+
let j;
|
|
180
|
+
try {
|
|
181
|
+
j = JSON.parse(text);
|
|
182
|
+
} catch {
|
|
183
|
+
throw new Error("uzu auth: invalid token response");
|
|
184
|
+
}
|
|
185
|
+
if (!j.access_token) throw new Error("uzu auth: missing access_token in response");
|
|
186
|
+
return {
|
|
187
|
+
accessToken: j.access_token,
|
|
188
|
+
refreshToken: j.refresh_token ?? "",
|
|
189
|
+
expiresIn: Number(j.expires_in) || 3600
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
var exchangeCodeForTokens = async (baseUrl, code, codeVerifier, redirectUri) => {
|
|
193
|
+
const tokens = await requestToken(baseUrl, {
|
|
194
|
+
grant_type: "authorization_code",
|
|
195
|
+
code,
|
|
196
|
+
code_verifier: codeVerifier,
|
|
197
|
+
redirect_uri: redirectUri,
|
|
198
|
+
client_id: CLIENT_ID
|
|
199
|
+
});
|
|
200
|
+
if (!tokens.refreshToken) throw new Error("uzu auth: missing refresh_token in response");
|
|
201
|
+
return tokens;
|
|
202
|
+
};
|
|
203
|
+
var refreshTokens = (baseUrl, refreshToken) => requestToken(baseUrl, { grant_type: "refresh_token", refresh_token: refreshToken });
|
|
204
|
+
var exchangePublishToken = (baseUrl, publishToken) => requestToken(baseUrl, {
|
|
205
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
206
|
+
subject_token: publishToken,
|
|
207
|
+
subject_token_type: PUBLISH_TOKEN_TYPE
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// src/auth/token-cache.ts
|
|
211
|
+
var REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
212
|
+
var memoryCache = {};
|
|
213
|
+
var mintFromCredentials = async (env) => {
|
|
214
|
+
const creds = await getCredentials(env);
|
|
215
|
+
if (!creds) return null;
|
|
216
|
+
const refreshed = await refreshTokens(authBaseURL(env), creds.refreshToken);
|
|
217
|
+
if (refreshed.refreshToken && refreshed.refreshToken !== creds.refreshToken) {
|
|
218
|
+
await saveCredentials(env, { ...creds, refreshToken: refreshed.refreshToken });
|
|
219
|
+
}
|
|
220
|
+
return { idToken: refreshed.accessToken, expiresIn: refreshed.expiresIn };
|
|
221
|
+
};
|
|
222
|
+
var getValidIdToken = async (env) => {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const cached = memoryCache[env];
|
|
225
|
+
if (cached && cached.expiresAt - now > REFRESH_SKEW_MS) {
|
|
226
|
+
return cached.idToken;
|
|
227
|
+
}
|
|
228
|
+
const publishToken = publishTokenFromEnv();
|
|
229
|
+
if (publishToken !== null) {
|
|
230
|
+
const tokens = await exchangePublishToken(authBaseURL(env), publishToken);
|
|
231
|
+
memoryCache[env] = {
|
|
232
|
+
idToken: tokens.accessToken,
|
|
233
|
+
expiresAt: now + tokens.expiresIn * 1e3
|
|
234
|
+
};
|
|
235
|
+
return tokens.accessToken;
|
|
236
|
+
}
|
|
237
|
+
const minted = await mintFromCredentials(env);
|
|
238
|
+
if (minted === null) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`\u672A\u30ED\u30B0\u30A4\u30F3\u3067\u3059\u3002\`uzu login --env ${env}\` \u3092\u5B9F\u884C\u3059\u308B\u304B\u3001${PUBLISH_TOKEN_ENV} \u306B publish token \u3092\u8A2D\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
memoryCache[env] = { idToken: minted.idToken, expiresAt: now + minted.expiresIn * 1e3 };
|
|
244
|
+
return minted.idToken;
|
|
245
|
+
};
|
|
246
|
+
var getLoginIdToken = async (env) => {
|
|
247
|
+
const minted = await mintFromCredentials(env);
|
|
248
|
+
if (minted === null) {
|
|
249
|
+
throw new Error(`\u672A\u30ED\u30B0\u30A4\u30F3\u3067\u3059\u3002\`uzu login --env ${env}\` \u3092\u5148\u306B\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044`);
|
|
250
|
+
}
|
|
251
|
+
return minted.idToken;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/upload-session.ts
|
|
255
|
+
var isUpload = (v) => typeof v === "object" && v !== null && "path" in v && typeof v.path === "string" && "url" in v && typeof v.url === "string" && "contentType" in v && typeof v.contentType === "string";
|
|
256
|
+
var isImage = (v) => typeof v === "object" && v !== null && "uploadURL" in v && typeof v.uploadURL === "string";
|
|
257
|
+
var parseUploadSession = (v) => {
|
|
258
|
+
if (typeof v === "object" && v !== null && "resourceId" in v && typeof v.resourceId === "string" && "revisionId" in v && typeof v.revisionId === "string" && "token" in v && typeof v.token === "string" && "uploads" in v && Array.isArray(v.uploads) && v.uploads.every(isUpload) && "images" in v && Array.isArray(v.images) && v.images.every(isImage) && "imageDeliveryBaseURL" in v && typeof v.imageDeliveryBaseURL === "string") {
|
|
259
|
+
return {
|
|
260
|
+
resourceId: v.resourceId,
|
|
261
|
+
revisionId: v.revisionId,
|
|
262
|
+
token: v.token,
|
|
263
|
+
uploads: v.uploads,
|
|
264
|
+
images: v.images,
|
|
265
|
+
imageDeliveryBaseURL: v.imageDeliveryBaseURL
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
throw new Error("\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u30BB\u30C3\u30B7\u30E7\u30F3\u306E\u5FDC\u7B54\u304C\u60F3\u5B9A\u5916\u306E\u5F62\u5F0F\u3067\u3059");
|
|
269
|
+
};
|
|
270
|
+
var createUploadSession = async (params) => {
|
|
271
|
+
const idToken = await getValidIdToken(params.env);
|
|
272
|
+
const res = await fetch(`${graphBaseURL(params.env)}/uzu_code/upload_sessions`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: {
|
|
275
|
+
authorization: `bearer ${idToken}`,
|
|
276
|
+
"content-type": "application/json"
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify({
|
|
279
|
+
...params.token ? { token: params.token } : {},
|
|
280
|
+
files: params.files ?? [],
|
|
281
|
+
imageCount: params.imageCount ?? 0
|
|
282
|
+
})
|
|
283
|
+
});
|
|
284
|
+
if (!res.ok) {
|
|
285
|
+
const detail = (await res.text()).trim();
|
|
286
|
+
throw new Error(`\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u30BB\u30C3\u30B7\u30E7\u30F3\u306E\u767A\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F (HTTP ${res.status}): ${detail}`);
|
|
287
|
+
}
|
|
288
|
+
return parseUploadSession(await res.json());
|
|
289
|
+
};
|
|
290
|
+
var putToPresignedURL = async (upload, body) => {
|
|
291
|
+
const res = await fetch(upload.url, {
|
|
292
|
+
method: "PUT",
|
|
293
|
+
headers: { "content-type": upload.contentType },
|
|
294
|
+
body: typeof body === "string" ? body : new Blob([body])
|
|
295
|
+
});
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
const detail = (await res.text()).trim();
|
|
298
|
+
throw new Error(`\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u306B\u5931\u6557\u3057\u307E\u3057\u305F (${upload.path}, HTTP ${res.status}): ${detail}`);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// src/r2-upload.ts
|
|
303
|
+
var getMimeType = (path) => mime.contentType(basename(path)) || "application/octet-stream";
|
|
304
|
+
var UPLOAD_CONCURRENCY = 16;
|
|
305
|
+
var listFilesRecursively = async (rootDir) => {
|
|
306
|
+
const entries = await readdir(rootDir, { withFileTypes: true, recursive: true });
|
|
307
|
+
return entries.filter((e) => e.isFile()).map((e) => join2(e.parentPath, e.name));
|
|
308
|
+
};
|
|
309
|
+
var runWithConcurrency = async (items, concurrency, worker) => {
|
|
310
|
+
let index = 0;
|
|
311
|
+
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
312
|
+
while (true) {
|
|
313
|
+
const current = index++;
|
|
314
|
+
if (current >= items.length) return;
|
|
315
|
+
await worker(items[current]);
|
|
76
316
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
317
|
+
});
|
|
318
|
+
await Promise.all(runners);
|
|
319
|
+
};
|
|
320
|
+
var uploadGameToR2 = async (env, zipPath, outputDir) => {
|
|
321
|
+
const filePaths = await listFilesRecursively(outputDir);
|
|
322
|
+
const files = filePaths.map((absPath) => ({
|
|
323
|
+
absPath,
|
|
324
|
+
relPath: relative(outputDir, absPath).split(sep).join("/")
|
|
325
|
+
}));
|
|
326
|
+
const session = await createUploadSession({
|
|
327
|
+
env,
|
|
328
|
+
files: [
|
|
329
|
+
{ path: "data.zip", contentType: "application/zip" },
|
|
330
|
+
...files.map((f) => ({ path: `files/${f.relPath}`, contentType: getMimeType(f.relPath) }))
|
|
331
|
+
]
|
|
332
|
+
});
|
|
333
|
+
const uploadByPath = new Map(session.uploads.map((u) => [u.path, u]));
|
|
334
|
+
const uploadFor = (path) => {
|
|
335
|
+
const upload = uploadByPath.get(path);
|
|
336
|
+
if (!upload) throw new Error(`\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9 URL \u304C\u767A\u884C\u3055\u308C\u3066\u3044\u307E\u305B\u3093: ${path}`);
|
|
337
|
+
return upload;
|
|
338
|
+
};
|
|
339
|
+
await putToPresignedURL(uploadFor("data.zip"), readFileSync(zipPath));
|
|
340
|
+
console.log(`Uploading ${files.length} files to R2 (concurrency=${UPLOAD_CONCURRENCY})...`);
|
|
341
|
+
let uploaded = 0;
|
|
342
|
+
await runWithConcurrency(files, UPLOAD_CONCURRENCY, async (f) => {
|
|
343
|
+
await putToPresignedURL(uploadFor(`files/${f.relPath}`), readFileSync(f.absPath));
|
|
344
|
+
uploaded++;
|
|
345
|
+
if (uploaded % 50 === 0 || uploaded === files.length) {
|
|
346
|
+
console.log(` ${uploaded}/${files.length}`);
|
|
80
347
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
348
|
+
});
|
|
349
|
+
return { resourceId: session.resourceId, revisionId: session.revisionId, token: session.token };
|
|
350
|
+
};
|
|
351
|
+
var uploadLogicToR2 = async (env, token, logicJs) => {
|
|
352
|
+
const session = await createUploadSession({
|
|
353
|
+
env,
|
|
354
|
+
token,
|
|
355
|
+
files: [{ path: "logic.js", contentType: "application/javascript" }]
|
|
356
|
+
});
|
|
357
|
+
if (session.uploads.length !== 1) {
|
|
358
|
+
throw new Error("logic.js \u306E\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9 URL \u304C\u767A\u884C\u3055\u308C\u3066\u3044\u307E\u305B\u3093");
|
|
359
|
+
}
|
|
360
|
+
await putToPresignedURL(session.uploads[0], logicJs);
|
|
361
|
+
console.log(`Uploaded logic.js to R2: ${session.revisionId}/logic.js`);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// src/rest-register.ts
|
|
365
|
+
var buildCharacters = (params) => params.characters ? params.characters.map((char) => ({
|
|
366
|
+
id: char.id,
|
|
367
|
+
name: char.name,
|
|
368
|
+
description: char.description ?? "",
|
|
369
|
+
iconPath: char.icon ?? "",
|
|
370
|
+
...char.furigana ? { furigana: char.furigana } : {}
|
|
371
|
+
})) : Array.from({ length: params.playerCount }, (_, i) => ({
|
|
372
|
+
id: `player${i + 1}`,
|
|
373
|
+
name: `Player ${i + 1}`,
|
|
374
|
+
description: "",
|
|
375
|
+
iconPath: `https://ui-avatars.com/api/?name=Player+${i + 1}&background=random&size=128`
|
|
376
|
+
}));
|
|
377
|
+
var parseRegisterResponse = (body) => {
|
|
378
|
+
if (typeof body === "object" && body !== null && "scenarioRevisionId" in body && typeof body.scenarioRevisionId === "number") {
|
|
379
|
+
return body.scenarioRevisionId;
|
|
380
|
+
}
|
|
381
|
+
throw new Error("scenarioRevisionId \u3092\u542B\u307E\u306A\u3044\u5FDC\u7B54\u3092\u53D7\u3051\u53D6\u308A\u307E\u3057\u305F");
|
|
382
|
+
};
|
|
383
|
+
var registerRevision = async (params) => {
|
|
384
|
+
const idToken = await getValidIdToken(params.env);
|
|
385
|
+
const res = await fetch(`${graphBaseURL(params.env)}/uzu_code/scenario_revisions`, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
headers: {
|
|
388
|
+
authorization: `bearer ${idToken}`,
|
|
389
|
+
"content-type": "application/json"
|
|
390
|
+
},
|
|
391
|
+
body: JSON.stringify({
|
|
392
|
+
scenarioId: params.gameId,
|
|
393
|
+
configResourceId: params.resourceId,
|
|
394
|
+
uploadToken: params.uploadToken,
|
|
395
|
+
configSchemaVersion: "3.0.0",
|
|
396
|
+
changeNotes: params.changeNotes,
|
|
397
|
+
orientation: params.orientation,
|
|
398
|
+
characters: buildCharacters(params),
|
|
399
|
+
uzuCodeManifest: params.manifest,
|
|
400
|
+
uzuCodePublishMeta: params.publishMeta
|
|
401
|
+
})
|
|
402
|
+
});
|
|
403
|
+
if (!res.ok) {
|
|
404
|
+
const detail = (await res.text()).trim();
|
|
405
|
+
throw new Error(`revision \u306E\u767B\u9332\u306B\u5931\u6557\u3057\u307E\u3057\u305F (HTTP ${res.status}): ${detail}`);
|
|
406
|
+
}
|
|
407
|
+
return parseRegisterResponse(await res.json());
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// src/build-server-logic.ts
|
|
411
|
+
import { build } from "esbuild";
|
|
412
|
+
var buildServerLogic = async (logicPath, outPath) => {
|
|
413
|
+
await build({
|
|
414
|
+
entryPoints: [logicPath],
|
|
415
|
+
outfile: outPath,
|
|
416
|
+
bundle: true,
|
|
417
|
+
format: "esm",
|
|
418
|
+
target: "es2022",
|
|
419
|
+
platform: "neutral",
|
|
420
|
+
// SDK の type-only import は tsc で消えるが安全のため external 指定。
|
|
421
|
+
// @uzupj/uzu-sdk は旧パッケージ名 (未移行 scenario 向けの両対応)
|
|
422
|
+
external: ["@uzuhq/code-sdk", "@uzupj/uzu-sdk"]
|
|
423
|
+
});
|
|
424
|
+
console.log(`Built server logic: ${logicPath} \u2192 ${outPath}`);
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// src/create-2d-game.ts
|
|
428
|
+
import { mkdirSync, readFileSync as readFileSync2, writeFileSync, readdirSync, statSync } from "fs";
|
|
429
|
+
import { resolve, dirname, join as join3 } from "path";
|
|
430
|
+
import { fileURLToPath } from "url";
|
|
431
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
|
432
|
+
var copyDir = (src, dest, replacements) => {
|
|
433
|
+
mkdirSync(dest, { recursive: true });
|
|
434
|
+
for (const entry of readdirSync(src)) {
|
|
435
|
+
const srcPath = join3(src, entry);
|
|
436
|
+
const stat = statSync(srcPath);
|
|
437
|
+
if (stat.isDirectory()) {
|
|
438
|
+
copyDir(srcPath, join3(dest, entry), replacements);
|
|
439
|
+
continue;
|
|
95
440
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
441
|
+
if (entry.endsWith(".tpl")) {
|
|
442
|
+
let content = readFileSync2(srcPath, "utf-8");
|
|
443
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
444
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
445
|
+
}
|
|
446
|
+
writeFileSync(join3(dest, entry.replace(/\.tpl$/, "")), content);
|
|
447
|
+
} else {
|
|
448
|
+
const content = readFileSync2(srcPath, "utf-8");
|
|
449
|
+
let output = content;
|
|
450
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
451
|
+
output = output.replaceAll(`{{${key}}}`, value);
|
|
452
|
+
}
|
|
453
|
+
writeFileSync(join3(dest, entry), output);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var toTitle = (name) => name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
458
|
+
var create2dGame = (name) => {
|
|
459
|
+
const dest = resolve(process.cwd(), name);
|
|
460
|
+
const templateDir = resolve(__dirname, "..", "game-2d-template");
|
|
461
|
+
const title = toTitle(name);
|
|
462
|
+
const replacements = { name, title };
|
|
463
|
+
console.log(`Creating 2D game project: ${name}`);
|
|
464
|
+
copyDir(templateDir, dest, replacements);
|
|
465
|
+
console.log(`
|
|
466
|
+
Done! Created ${name}/`);
|
|
467
|
+
console.log(`
|
|
468
|
+
Next steps:`);
|
|
469
|
+
console.log(` cd ${name}`);
|
|
470
|
+
console.log(` npm install`);
|
|
471
|
+
console.log(` npm run dev`);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// src/cf-images-upload.ts
|
|
475
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
476
|
+
import { basename as basename2 } from "path";
|
|
477
|
+
var parseUploadedImageId = (v) => {
|
|
478
|
+
if (typeof v === "object" && v !== null && "success" in v && v.success === true && "result" in v && typeof v.result === "object" && v.result !== null && "id" in v.result && typeof v.result.id === "string" && v.result.id !== "") {
|
|
479
|
+
return v.result.id;
|
|
480
|
+
}
|
|
481
|
+
throw new Error(`Cloudflare Images \u306E\u5FDC\u7B54\u304C\u60F3\u5B9A\u5916\u306E\u5F62\u5F0F\u3067\u3059: ${JSON.stringify(v)}`);
|
|
482
|
+
};
|
|
483
|
+
var uploadIconsToCfImages = async (env, token, filePaths) => {
|
|
484
|
+
if (filePaths.length === 0) return /* @__PURE__ */ new Map();
|
|
485
|
+
const session = await createUploadSession({ env, token, imageCount: filePaths.length });
|
|
486
|
+
if (session.images.length !== filePaths.length) {
|
|
487
|
+
throw new Error("\u30A2\u30A4\u30B3\u30F3\u306E\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9 URL \u306E\u767A\u884C\u6570\u304C\u4E00\u81F4\u3057\u307E\u305B\u3093");
|
|
488
|
+
}
|
|
489
|
+
const result = /* @__PURE__ */ new Map();
|
|
490
|
+
await Promise.all(
|
|
491
|
+
filePaths.map(async (filePath, i) => {
|
|
492
|
+
const image = session.images[i];
|
|
493
|
+
const formData = new FormData();
|
|
494
|
+
formData.append("file", new Blob([readFileSync3(filePath)]), basename2(filePath));
|
|
495
|
+
const res = await fetch(image.uploadURL, { method: "POST", body: formData });
|
|
496
|
+
if (!res.ok) {
|
|
497
|
+
const detail = (await res.text()).trim();
|
|
498
|
+
throw new Error(
|
|
499
|
+
`Cloudflare Images \u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u5931\u6557 (${filePath}, HTTP ${res.status}): ${detail}`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const imageId = parseUploadedImageId(await res.json());
|
|
503
|
+
result.set(filePath, `${session.imageDeliveryBaseURL}/${imageId}`);
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
return result;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// src/dev.ts
|
|
510
|
+
import { spawn } from "child_process";
|
|
511
|
+
import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
|
|
512
|
+
import { dirname as dirname3, resolve as resolve3 } from "path";
|
|
513
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
514
|
+
import { createInterface } from "readline";
|
|
515
|
+
|
|
516
|
+
// src/dev-server/server.ts
|
|
517
|
+
import {
|
|
518
|
+
Agent,
|
|
519
|
+
createServer,
|
|
520
|
+
request as httpRequest
|
|
521
|
+
} from "http";
|
|
522
|
+
import { connect as netConnect } from "net";
|
|
523
|
+
import { hostname, networkInterfaces } from "os";
|
|
524
|
+
import { WebSocketServer } from "ws";
|
|
525
|
+
|
|
526
|
+
// src/dev-server/game-room.ts
|
|
527
|
+
import { randomUUID } from "crypto";
|
|
528
|
+
|
|
529
|
+
// ../engine-core/src/json-patch.ts
|
|
530
|
+
function escapePointer(key) {
|
|
531
|
+
return key.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
532
|
+
}
|
|
533
|
+
function unescapePointer(token) {
|
|
534
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
535
|
+
}
|
|
536
|
+
function compare(oldObj, newObj, basePath = "") {
|
|
537
|
+
if (oldObj === newObj) return [];
|
|
538
|
+
if (oldObj === null || newObj === null || typeof oldObj !== "object" || typeof newObj !== "object") {
|
|
539
|
+
return [{ op: "replace", path: basePath || "/", value: newObj }];
|
|
540
|
+
}
|
|
541
|
+
if (Array.isArray(oldObj) || Array.isArray(newObj)) {
|
|
542
|
+
if (JSON.stringify(oldObj) === JSON.stringify(newObj)) return [];
|
|
543
|
+
return [{ op: "replace", path: basePath || "/", value: newObj }];
|
|
544
|
+
}
|
|
545
|
+
const ops = [];
|
|
546
|
+
const oldKeys = Object.keys(oldObj);
|
|
547
|
+
const newKeys = Object.keys(newObj);
|
|
548
|
+
for (const key of oldKeys) {
|
|
549
|
+
if (!(key in newObj)) {
|
|
550
|
+
ops.push({ op: "remove", path: `${basePath}/${escapePointer(key)}` });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const key of newKeys) {
|
|
554
|
+
const childPath = `${basePath}/${escapePointer(key)}`;
|
|
555
|
+
if (!(key in oldObj)) {
|
|
556
|
+
ops.push({ op: "add", path: childPath, value: newObj[key] });
|
|
557
|
+
} else {
|
|
558
|
+
const childOps = compare(oldObj[key], newObj[key], childPath);
|
|
559
|
+
ops.push(...childOps);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return ops;
|
|
563
|
+
}
|
|
564
|
+
function applyPatch(doc, ops) {
|
|
565
|
+
for (const op of ops) {
|
|
566
|
+
const tokens = op.path.split("/").slice(1).map(unescapePointer);
|
|
567
|
+
if (tokens.length === 0) return false;
|
|
568
|
+
if (op.op === "replace" || op.op === "add") {
|
|
569
|
+
let target = doc;
|
|
570
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
571
|
+
target = target?.[tokens[i]];
|
|
572
|
+
if (target === void 0 || target === null) return false;
|
|
573
|
+
}
|
|
574
|
+
const lastKey = tokens[tokens.length - 1];
|
|
575
|
+
target[lastKey] = op.value;
|
|
576
|
+
} else if (op.op === "remove") {
|
|
577
|
+
let target = doc;
|
|
578
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
579
|
+
target = target?.[tokens[i]];
|
|
580
|
+
if (target === void 0 || target === null) return false;
|
|
581
|
+
}
|
|
582
|
+
const lastKey = tokens[tokens.length - 1];
|
|
583
|
+
if (Array.isArray(target)) {
|
|
584
|
+
target.splice(Number(lastKey), 1);
|
|
585
|
+
} else {
|
|
586
|
+
delete target[lastKey];
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// ../engine-core/src/random.ts
|
|
594
|
+
var SeededRandomImpl = class _SeededRandomImpl {
|
|
595
|
+
_state;
|
|
596
|
+
constructor(seed) {
|
|
597
|
+
this._state = seed | 0;
|
|
598
|
+
}
|
|
599
|
+
get state() {
|
|
600
|
+
return this._state;
|
|
601
|
+
}
|
|
602
|
+
static fromState(state) {
|
|
603
|
+
const r = new _SeededRandomImpl(0);
|
|
604
|
+
r._state = state;
|
|
605
|
+
return r;
|
|
606
|
+
}
|
|
607
|
+
float() {
|
|
608
|
+
this._state |= 0;
|
|
609
|
+
this._state = this._state + 1831565813 | 0;
|
|
610
|
+
let t = Math.imul(this._state ^ this._state >>> 15, 1 | this._state);
|
|
611
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
612
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
613
|
+
}
|
|
614
|
+
int(max) {
|
|
615
|
+
return Math.floor(this.float() * max);
|
|
616
|
+
}
|
|
617
|
+
pick(array) {
|
|
618
|
+
return array[this.int(array.length)];
|
|
619
|
+
}
|
|
620
|
+
shuffle(array) {
|
|
621
|
+
const a = [...array];
|
|
622
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
623
|
+
const j = this.int(i + 1);
|
|
624
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
103
625
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
626
|
+
return a;
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// ../engine-core/src/server-only.ts
|
|
631
|
+
function isServerOnlyAction(handler) {
|
|
632
|
+
return typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ../engine-core/src/roster.ts
|
|
636
|
+
function parseRoster(rosterParam) {
|
|
637
|
+
if (!rosterParam) return null;
|
|
638
|
+
let raw;
|
|
639
|
+
try {
|
|
640
|
+
raw = JSON.parse(rosterParam);
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
if (!Array.isArray(raw)) return null;
|
|
645
|
+
return raw.filter((p) => (p.kind ?? "player") === "player").map((p) => ({
|
|
646
|
+
id: p.id,
|
|
647
|
+
nickname: p.name ?? "Guest",
|
|
648
|
+
iconUrl: p.iconUrl ?? "",
|
|
649
|
+
characterId: p.characterId
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/dev-server/admin-state-patch.ts
|
|
654
|
+
var MERGE_PATCH_ARRAY_REJECT = "[applyJsonMergePatch] cannot merge a non-array patch into an array target";
|
|
655
|
+
function applyJsonMergePatch(target, patch) {
|
|
656
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return;
|
|
657
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
658
|
+
if (value === void 0) continue;
|
|
659
|
+
if (value === null) {
|
|
660
|
+
target[key] = null;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (Array.isArray(value)) {
|
|
664
|
+
target[key] = value;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
if (typeof value === "object") {
|
|
668
|
+
const existing = target[key];
|
|
669
|
+
if (Array.isArray(existing)) {
|
|
670
|
+
throw new Error(MERGE_PATCH_ARRAY_REJECT);
|
|
671
|
+
}
|
|
672
|
+
if (existing === null || typeof existing !== "object") {
|
|
673
|
+
target[key] = value;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
applyJsonMergePatch(existing, value);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
target[key] = value;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function applyJsonPatch(target, ops) {
|
|
683
|
+
const ok = applyPatch(target, ops);
|
|
684
|
+
if (!ok) {
|
|
685
|
+
throw new Error("[applyJsonPatch] failed to apply one or more operations");
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// src/dev-server/game-room.ts
|
|
690
|
+
var GameRoom = class _GameRoom {
|
|
691
|
+
logic;
|
|
692
|
+
tickRate;
|
|
693
|
+
gameState = null;
|
|
694
|
+
stateInitialized = false;
|
|
695
|
+
random = null;
|
|
696
|
+
seed = 0;
|
|
697
|
+
tickCount = 0;
|
|
698
|
+
tickTimer = null;
|
|
699
|
+
tickPaused = false;
|
|
700
|
+
playerInputs = {};
|
|
701
|
+
/**
|
|
702
|
+
* roster。 配役を受け取る player 席のみで、 観測席 (GM 席・観戦席) は載らない。
|
|
703
|
+
* dev harness では manifest から組んだ roster を constructor で注入する
|
|
704
|
+
* (server 権威)。 その場合、 接続クエリの roster 申告は一切採用しないので、
|
|
705
|
+
* 旧世代 harness page の残タブが reconnect しても roster を汚染できない。
|
|
706
|
+
* 本番 DO は backend 由来の roster を全 client が同一申告するため接続時
|
|
707
|
+
* 登録で成立している — 権威が platform 側にある点は同じ。
|
|
708
|
+
*/
|
|
709
|
+
players = [];
|
|
710
|
+
seq = 0;
|
|
711
|
+
prevBroadcastState = null;
|
|
712
|
+
static SNAPSHOT_INTERVAL = 20;
|
|
713
|
+
sockets = /* @__PURE__ */ new Set();
|
|
714
|
+
attachments = /* @__PURE__ */ new WeakMap();
|
|
715
|
+
snapshotSubscribers = /* @__PURE__ */ new Set();
|
|
716
|
+
eventSubscribers = /* @__PURE__ */ new Set();
|
|
717
|
+
constructor(logic, players) {
|
|
718
|
+
this.logic = logic;
|
|
719
|
+
this.tickRate = logic.tickRate ?? 0;
|
|
720
|
+
if (players && players.length > 0) {
|
|
721
|
+
this.players = players;
|
|
722
|
+
console.log(
|
|
723
|
+
`[GameRoom] \u{1F4CB} Roster (server-authoritative): ${players.map((p) => p.id).join(", ")}`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// ─── Broadcast ─────────────────────────────────────────
|
|
728
|
+
broadcastAll(msg) {
|
|
729
|
+
const data = JSON.stringify(msg);
|
|
730
|
+
for (const ws of this.sockets) {
|
|
731
|
+
try {
|
|
732
|
+
ws.send(data);
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
sendTo(ws, msg) {
|
|
111
738
|
try {
|
|
112
|
-
|
|
739
|
+
ws.send(JSON.stringify(msg));
|
|
740
|
+
} catch {
|
|
113
741
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
742
|
+
}
|
|
743
|
+
broadcastStateDelta(events, extra) {
|
|
744
|
+
this.seq++;
|
|
745
|
+
const serverTime = Date.now();
|
|
746
|
+
const fullType = extra.ack !== void 0 ? "__action_result" : "__tick";
|
|
747
|
+
const deltaType = extra.ack !== void 0 ? "__action_result_delta" : "__tick_delta";
|
|
748
|
+
const needFull = this.prevBroadcastState === null || this.seq % _GameRoom.SNAPSHOT_INTERVAL === 0;
|
|
749
|
+
if (needFull) {
|
|
750
|
+
this.broadcastAll({
|
|
751
|
+
type: fullType,
|
|
752
|
+
state: this.gameState,
|
|
753
|
+
events,
|
|
754
|
+
seq: this.seq,
|
|
755
|
+
serverTime,
|
|
756
|
+
...extra
|
|
757
|
+
});
|
|
758
|
+
} else {
|
|
759
|
+
const patches = compare(this.prevBroadcastState, this.gameState);
|
|
760
|
+
const deltaPayload = JSON.stringify({
|
|
761
|
+
type: deltaType,
|
|
762
|
+
patches,
|
|
763
|
+
events,
|
|
764
|
+
seq: this.seq,
|
|
765
|
+
serverTime,
|
|
766
|
+
...extra
|
|
767
|
+
});
|
|
768
|
+
const fullPayload = JSON.stringify({
|
|
769
|
+
type: fullType,
|
|
770
|
+
state: this.gameState,
|
|
771
|
+
events,
|
|
772
|
+
seq: this.seq,
|
|
773
|
+
serverTime,
|
|
774
|
+
...extra
|
|
775
|
+
});
|
|
776
|
+
const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
|
|
777
|
+
for (const ws of this.sockets) {
|
|
778
|
+
try {
|
|
779
|
+
ws.send(data);
|
|
780
|
+
} catch {
|
|
781
|
+
}
|
|
782
|
+
}
|
|
117
783
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
784
|
+
this.prevBroadcastState = structuredClone(this.gameState);
|
|
785
|
+
this.notifySnapshotSubscribers();
|
|
786
|
+
if (events.length > 0) this.notifyEventSubscribers(events);
|
|
787
|
+
}
|
|
788
|
+
notifySnapshotSubscribers() {
|
|
789
|
+
this.snapshotSubscribers.forEach((cb) => {
|
|
790
|
+
try {
|
|
791
|
+
cb(this.gameState);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
console.warn("[GameRoom] snapshot subscriber threw:", err);
|
|
794
|
+
}
|
|
123
795
|
});
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await new Promise((res, reject) => {
|
|
134
|
-
const output = createWriteStream(zipPath);
|
|
135
|
-
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
136
|
-
output.on('close', () => res());
|
|
137
|
-
archive.on('error', (err) => reject(err));
|
|
138
|
-
archive.pipe(output);
|
|
139
|
-
archive.directory(absoluteOutputDir, false);
|
|
140
|
-
archive.finalize();
|
|
796
|
+
}
|
|
797
|
+
notifyEventSubscribers(events) {
|
|
798
|
+
const frozen = Object.freeze(events.slice());
|
|
799
|
+
this.eventSubscribers.forEach((cb) => {
|
|
800
|
+
try {
|
|
801
|
+
cb(frozen);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.warn("[GameRoom] event subscriber threw:", err);
|
|
804
|
+
}
|
|
141
805
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
806
|
+
}
|
|
807
|
+
// ─── Game Lifecycle ────────────────────────────────────
|
|
808
|
+
/**
|
|
809
|
+
* 移行期: 手元のシナリオが `setup({ seats })` のままでも `uzu dev` を動かせるよう、
|
|
810
|
+
* 同じ配列を旧名でも渡す。 本番 (play-server) が publish 済み logic.js のために
|
|
811
|
+
* 同じことをしているのと揃えている。 `SetupArgs` に `seats` を宣言しないのは、
|
|
812
|
+
* 新規シナリオに旧名を選ばせないため。
|
|
813
|
+
*/
|
|
814
|
+
setupArgs(random) {
|
|
815
|
+
return {
|
|
816
|
+
players: this.players,
|
|
817
|
+
seats: this.players,
|
|
818
|
+
ctx: { random, now: Date.now() }
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
maybeStartGame() {
|
|
822
|
+
if (this.gameState !== null) return;
|
|
823
|
+
if (this.players.length === 0) return;
|
|
824
|
+
this.seed = Date.now() & 4294967295;
|
|
825
|
+
this.random = new SeededRandomImpl(this.seed);
|
|
826
|
+
this.gameState = this.logic.setup(this.setupArgs(this.random));
|
|
827
|
+
this.stateInitialized = true;
|
|
828
|
+
this.tickCount = 0;
|
|
829
|
+
this.seq = 0;
|
|
830
|
+
this.prevBroadcastState = structuredClone(this.gameState);
|
|
831
|
+
console.log(`[GameRoom] \u2705 Game started with ${this.players.length} players`);
|
|
832
|
+
this.broadcastAll({
|
|
833
|
+
type: "__game_start",
|
|
834
|
+
state: this.gameState,
|
|
835
|
+
seed: this.seed,
|
|
836
|
+
seq: 0
|
|
837
|
+
});
|
|
838
|
+
if (this.tickRate > 0) this.startTickLoop();
|
|
839
|
+
this.notifySnapshotSubscribers();
|
|
840
|
+
}
|
|
841
|
+
startTickLoop() {
|
|
842
|
+
if (this.tickTimer) clearInterval(this.tickTimer);
|
|
843
|
+
this.tickTimer = setInterval(() => this.tick(), 1e3 / this.tickRate);
|
|
844
|
+
}
|
|
845
|
+
// 全クライアント切断で tickTimer は止まる (handleClose) が gameState は残るため、
|
|
846
|
+
// 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
|
|
847
|
+
ensureTickLoop() {
|
|
848
|
+
if (this.tickRate > 0 && this.gameState !== null && this.sockets.size > 0 && !this.tickPaused && !this.tickTimer) {
|
|
849
|
+
this.startTickLoop();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
tick() {
|
|
853
|
+
if (this.tickPaused) return;
|
|
854
|
+
this.runOneTick();
|
|
855
|
+
}
|
|
856
|
+
runOneTick() {
|
|
857
|
+
if (!this.gameState || !this.random) return;
|
|
858
|
+
const events = [];
|
|
859
|
+
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
860
|
+
const tickNow = Date.now();
|
|
861
|
+
try {
|
|
862
|
+
this.logic.update({
|
|
863
|
+
state: this.gameState,
|
|
864
|
+
ctx: {
|
|
865
|
+
random: this.random,
|
|
866
|
+
tick: this.tickCount,
|
|
867
|
+
now: tickNow,
|
|
868
|
+
emit,
|
|
869
|
+
playerInputs: this.playerInputs
|
|
177
870
|
}
|
|
871
|
+
});
|
|
872
|
+
} catch (err) {
|
|
873
|
+
console.error(`[GameRoom] tick error at tick=${this.tickCount}:`, err);
|
|
874
|
+
this.tickCount++;
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
this.tickCount++;
|
|
878
|
+
this.syncWakeup();
|
|
879
|
+
this.broadcastStateDelta(events, { tick: this.tickCount });
|
|
880
|
+
}
|
|
881
|
+
// ─── Connection ────────────────────────────────────────
|
|
882
|
+
handleConnection(ws, url) {
|
|
883
|
+
const playerId = url.searchParams.get("seatId");
|
|
884
|
+
if (!playerId) {
|
|
885
|
+
ws.close(1008, "Missing required query parameter: seatId");
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const nickname = url.searchParams.get("nickname") ?? "Guest";
|
|
889
|
+
const connectionId = randomUUID();
|
|
890
|
+
if (this.players.length === 0) {
|
|
891
|
+
const declared = parseRoster(
|
|
892
|
+
url.searchParams.get("players") ?? url.searchParams.get("seats")
|
|
893
|
+
);
|
|
894
|
+
if (declared && declared.length > 0) {
|
|
895
|
+
this.players = declared;
|
|
896
|
+
console.log(`[GameRoom] \u{1F4CB} Roster pinned: ${this.players.map((p) => p.id).join(", ")}`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
console.log(
|
|
900
|
+
`[GameRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`
|
|
901
|
+
);
|
|
902
|
+
this.sockets.add(ws);
|
|
903
|
+
this.attachments.set(ws, { connectionId, playerId, nickname });
|
|
904
|
+
ws.on("message", (raw) => {
|
|
905
|
+
void this.handleMessage(ws, raw.toString());
|
|
906
|
+
});
|
|
907
|
+
ws.on("close", () => this.handleClose(ws));
|
|
908
|
+
ws.on("error", () => this.handleClose(ws));
|
|
909
|
+
this.sendTo(ws, {
|
|
910
|
+
type: "__room_init",
|
|
911
|
+
myId: playerId
|
|
912
|
+
});
|
|
913
|
+
if (this.stateInitialized && this.gameState !== null) {
|
|
914
|
+
this.sendTo(ws, {
|
|
915
|
+
type: "__state",
|
|
916
|
+
state: this.gameState,
|
|
917
|
+
tick: this.tickCount,
|
|
918
|
+
seq: this.seq,
|
|
919
|
+
serverTime: Date.now()
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
this.maybeStartGame();
|
|
923
|
+
this.ensureTickLoop();
|
|
924
|
+
}
|
|
925
|
+
async handleMessage(ws, msg) {
|
|
926
|
+
if (msg === "__ping") {
|
|
927
|
+
try {
|
|
928
|
+
ws.send("__pong");
|
|
929
|
+
} catch {
|
|
930
|
+
}
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
const attachment = this.attachments.get(ws);
|
|
934
|
+
if (!attachment) return;
|
|
935
|
+
const senderId = attachment.playerId;
|
|
936
|
+
let parsed;
|
|
937
|
+
try {
|
|
938
|
+
parsed = JSON.parse(msg);
|
|
939
|
+
} catch {
|
|
940
|
+
return;
|
|
178
941
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
console.error(`アイコンファイルが見つかりません: ${char.icon}`);
|
|
188
|
-
process.exit(1);
|
|
189
|
-
}
|
|
190
|
-
return iconAbsPath;
|
|
942
|
+
const msgType = parsed.type;
|
|
943
|
+
console.log(`[GameRoom] \u2B05 recv from=${senderId} type=${msgType}`);
|
|
944
|
+
if (msgType === "__action") {
|
|
945
|
+
if (!this.gameState) {
|
|
946
|
+
this.sendTo(ws, {
|
|
947
|
+
type: "__action_error",
|
|
948
|
+
error: "Game not started",
|
|
949
|
+
seq: parsed.seq
|
|
191
950
|
});
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const actionName = parsed.action;
|
|
954
|
+
const payload = parsed.payload ?? {};
|
|
955
|
+
const seq = parsed.seq;
|
|
956
|
+
try {
|
|
957
|
+
await this.dispatchAction(actionName, payload, senderId, seq);
|
|
958
|
+
} catch (err) {
|
|
959
|
+
this.sendTo(ws, {
|
|
960
|
+
type: "__action_error",
|
|
961
|
+
error: `Action failed: ${err instanceof Error ? err.message : "unknown"}`,
|
|
962
|
+
seq
|
|
203
963
|
});
|
|
964
|
+
}
|
|
965
|
+
return;
|
|
204
966
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
967
|
+
if (msgType === "__request_state") {
|
|
968
|
+
if (this.gameState !== null) {
|
|
969
|
+
this.sendTo(ws, {
|
|
970
|
+
type: "__state",
|
|
971
|
+
state: this.gameState,
|
|
972
|
+
tick: this.tickCount,
|
|
973
|
+
seq: this.seq,
|
|
974
|
+
serverTime: Date.now()
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (msgType === "__input") {
|
|
980
|
+
const inputData = parsed.data;
|
|
981
|
+
if (inputData) this.playerInputs[senderId] = inputData;
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async dispatchAction(actionName, payload, senderId, ackSeq) {
|
|
986
|
+
const plain = this.logic.actions[actionName];
|
|
987
|
+
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
988
|
+
const serverHandler = this.logic.serverActions?.[actionName] ?? legacyServerOnly;
|
|
989
|
+
if (!plain && !serverHandler) {
|
|
990
|
+
throw new Error(`Unknown action: ${actionName}`);
|
|
991
|
+
}
|
|
992
|
+
if (!this.gameState) {
|
|
993
|
+
throw new Error("Game not started");
|
|
994
|
+
}
|
|
995
|
+
const events = [];
|
|
996
|
+
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
997
|
+
const serverEmit = emit;
|
|
998
|
+
const snapshot = structuredClone(this.gameState);
|
|
999
|
+
const now = Date.now();
|
|
233
1000
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
1001
|
+
if (plain && !legacyServerOnly) {
|
|
1002
|
+
plain({
|
|
1003
|
+
state: this.gameState,
|
|
1004
|
+
payload: payload ?? {},
|
|
1005
|
+
playerId: senderId,
|
|
1006
|
+
ctx: { now, emit }
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
if (serverHandler) {
|
|
1010
|
+
await serverHandler({
|
|
1011
|
+
state: this.gameState,
|
|
1012
|
+
payload: payload ?? {},
|
|
1013
|
+
playerId: senderId,
|
|
1014
|
+
ctx: {
|
|
1015
|
+
tick: this.tickCount,
|
|
1016
|
+
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
1017
|
+
now,
|
|
1018
|
+
emit: serverEmit
|
|
1019
|
+
}
|
|
238
1020
|
});
|
|
1021
|
+
}
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
this.gameState = snapshot;
|
|
1024
|
+
throw err;
|
|
239
1025
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
1026
|
+
this.syncWakeup();
|
|
1027
|
+
this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
|
|
1028
|
+
}
|
|
1029
|
+
// ─── Deadlines (本番の Durable Object alarm 相当) ──────────
|
|
1030
|
+
/** dev は setTimeout。本番は storage の alarm。意味論は同じ。 */
|
|
1031
|
+
wakeupTimer = null;
|
|
1032
|
+
wakeupAt = null;
|
|
1033
|
+
/**
|
|
1034
|
+
* 宣言された締切のうち最も早い時刻。締切が無ければ null。
|
|
1035
|
+
* state から毎回導出するので保存しない。
|
|
1036
|
+
*/
|
|
1037
|
+
nextDeadline() {
|
|
1038
|
+
if (!this.gameState || !this.logic.deadlines) return null;
|
|
1039
|
+
let earliest = null;
|
|
1040
|
+
for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
|
|
1041
|
+
let at;
|
|
1042
|
+
try {
|
|
1043
|
+
at = deadline.at({ state: this.gameState });
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
console.error(`[Deadline] \u274C ${key}.at() \u3067\u4F8B\u5916`, err);
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (typeof at !== "number" || !Number.isFinite(at)) continue;
|
|
1049
|
+
if (earliest === null || at < earliest) earliest = at;
|
|
1050
|
+
}
|
|
1051
|
+
return earliest;
|
|
1052
|
+
}
|
|
1053
|
+
/** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
|
|
1054
|
+
syncWakeup() {
|
|
1055
|
+
const next = this.nextDeadline();
|
|
1056
|
+
if (next === this.wakeupAt) return;
|
|
1057
|
+
if (this.wakeupTimer) clearTimeout(this.wakeupTimer);
|
|
1058
|
+
this.wakeupTimer = null;
|
|
1059
|
+
this.wakeupAt = next;
|
|
1060
|
+
if (next === null) return;
|
|
1061
|
+
this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
|
|
1062
|
+
this.wakeupTimer.unref?.();
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
|
|
1066
|
+
*
|
|
1067
|
+
* handler の直前に at を評価し直す (先に走った handler が別の締切を消しうる)。
|
|
1068
|
+
* 失敗は握って捨てる。ログには必ず残す。
|
|
1069
|
+
*/
|
|
1070
|
+
fireDue() {
|
|
1071
|
+
this.wakeupTimer = null;
|
|
1072
|
+
this.wakeupAt = null;
|
|
1073
|
+
if (!this.gameState || !this.logic.deadlines) return;
|
|
1074
|
+
const now = Date.now();
|
|
1075
|
+
const events = [];
|
|
1076
|
+
const firedKeys = [];
|
|
1077
|
+
for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
|
|
1078
|
+
const state = this.gameState;
|
|
1079
|
+
if (!state) break;
|
|
1080
|
+
const pending = [];
|
|
1081
|
+
let snapshot = null;
|
|
1082
|
+
try {
|
|
1083
|
+
const at = deadline.at({ state });
|
|
1084
|
+
if (typeof at !== "number" || !Number.isFinite(at) || at > now) continue;
|
|
1085
|
+
snapshot = structuredClone(state);
|
|
1086
|
+
deadline.handler({
|
|
1087
|
+
state,
|
|
1088
|
+
ctx: {
|
|
1089
|
+
now,
|
|
1090
|
+
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
1091
|
+
emit: (name, data) => pending.push({ name, data: data ?? {} })
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
events.push(...pending);
|
|
1095
|
+
firedKeys.push(key);
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
if (snapshot !== null) this.gameState = snapshot;
|
|
1098
|
+
console.error(`[Deadline] \u274C ${key} \u3067\u4F8B\u5916`, err);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
this.syncWakeup();
|
|
1102
|
+
if (firedKeys.length === 0) return;
|
|
1103
|
+
console.log(`[Deadline] \u23F0 ${firedKeys.length} \u4EF6\u767A\u706B: ${firedKeys.join(", ")}`);
|
|
1104
|
+
this.broadcastStateDelta(events, {});
|
|
1105
|
+
}
|
|
1106
|
+
handleClose(ws) {
|
|
1107
|
+
const attachment = this.attachments.get(ws);
|
|
1108
|
+
if (!attachment) return;
|
|
1109
|
+
console.log(
|
|
1110
|
+
`[GameRoom] \u274C Disconnected: connectionId=${attachment.connectionId} playerId=${attachment.playerId}`
|
|
1111
|
+
);
|
|
1112
|
+
this.sockets.delete(ws);
|
|
1113
|
+
this.attachments.delete(ws);
|
|
1114
|
+
delete this.playerInputs[attachment.playerId];
|
|
1115
|
+
if (this.sockets.size === 0 && this.tickTimer) {
|
|
1116
|
+
clearInterval(this.tickTimer);
|
|
1117
|
+
this.tickTimer = null;
|
|
243
1118
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
1119
|
+
}
|
|
1120
|
+
// ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
|
|
1121
|
+
admin() {
|
|
1122
|
+
return {
|
|
1123
|
+
getSnapshot: () => this.gameState,
|
|
1124
|
+
getRawState: () => this.gameState,
|
|
1125
|
+
setRawState: (next) => {
|
|
1126
|
+
this.gameState = next;
|
|
1127
|
+
this.broadcastStateDelta([], { tick: this.tickCount });
|
|
1128
|
+
},
|
|
1129
|
+
mergeRawState: (patch) => {
|
|
1130
|
+
if (!this.gameState) return;
|
|
1131
|
+
applyJsonMergePatch(this.gameState, patch);
|
|
1132
|
+
this.broadcastStateDelta([], { tick: this.tickCount });
|
|
1133
|
+
},
|
|
1134
|
+
patchRawState: (ops) => {
|
|
1135
|
+
if (!this.gameState) return;
|
|
1136
|
+
applyJsonPatch(this.gameState, ops);
|
|
1137
|
+
this.broadcastStateDelta([], { tick: this.tickCount });
|
|
1138
|
+
},
|
|
1139
|
+
sendAction: async ({ as, type, payload }) => {
|
|
1140
|
+
await this.dispatchAction(type, payload, as);
|
|
1141
|
+
},
|
|
1142
|
+
getSeed: () => this.seed,
|
|
1143
|
+
pauseTick: () => {
|
|
1144
|
+
if (this.tickRate <= 0) return;
|
|
1145
|
+
this.tickPaused = true;
|
|
1146
|
+
},
|
|
1147
|
+
resumeTick: () => {
|
|
1148
|
+
if (this.tickRate <= 0) return;
|
|
1149
|
+
this.tickPaused = false;
|
|
1150
|
+
},
|
|
1151
|
+
stepTick: (n = 1) => {
|
|
1152
|
+
if (this.tickRate <= 0) return;
|
|
1153
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1154
|
+
throw new RangeError(`stepTick: n must be a non-negative integer (got ${n})`);
|
|
1155
|
+
}
|
|
1156
|
+
for (let i = 0; i < n; i++) this.runOneTick();
|
|
1157
|
+
},
|
|
1158
|
+
getCurrentTick: () => this.tickCount,
|
|
1159
|
+
isTickPaused: () => this.tickPaused,
|
|
1160
|
+
reset: (opts) => {
|
|
1161
|
+
if (opts?.seed === "random") {
|
|
1162
|
+
this.seed = Math.floor(Math.random() * 4294967295);
|
|
1163
|
+
} else if (typeof opts?.seed === "number") {
|
|
1164
|
+
this.seed = opts.seed;
|
|
1165
|
+
}
|
|
1166
|
+
this.random = new SeededRandomImpl(this.seed);
|
|
1167
|
+
this.gameState = this.logic.setup(this.setupArgs(this.random));
|
|
1168
|
+
this.tickCount = 0;
|
|
1169
|
+
this.tickPaused = false;
|
|
1170
|
+
this.seq = 0;
|
|
1171
|
+
this.prevBroadcastState = structuredClone(this.gameState);
|
|
1172
|
+
this.broadcastAll({
|
|
1173
|
+
type: "__game_start",
|
|
1174
|
+
state: this.gameState,
|
|
1175
|
+
seed: this.seed,
|
|
1176
|
+
seq: 0
|
|
1177
|
+
});
|
|
1178
|
+
this.notifySnapshotSubscribers();
|
|
1179
|
+
this.ensureTickLoop();
|
|
1180
|
+
},
|
|
1181
|
+
subscribeSnapshot: (cb) => {
|
|
1182
|
+
this.snapshotSubscribers.add(cb);
|
|
1183
|
+
return () => {
|
|
1184
|
+
this.snapshotSubscribers.delete(cb);
|
|
1185
|
+
};
|
|
1186
|
+
},
|
|
1187
|
+
subscribeEvents: (cb) => {
|
|
1188
|
+
this.eventSubscribers.add(cb);
|
|
1189
|
+
return () => {
|
|
1190
|
+
this.eventSubscribers.delete(cb);
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
// src/dev-server/sync-room.ts
|
|
1198
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1199
|
+
var SERVER_TIME_SENTINEL = "__SERVER_TIME__";
|
|
1200
|
+
function resolveServerTime(ops, now) {
|
|
1201
|
+
for (const op of ops) {
|
|
1202
|
+
if (op.value === SERVER_TIME_SENTINEL) {
|
|
1203
|
+
op.value = now;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
var SyncRoom = class _SyncRoom {
|
|
1208
|
+
cachedState = null;
|
|
1209
|
+
stateInitialized = false;
|
|
1210
|
+
seq = 0;
|
|
1211
|
+
patchesSinceReconciliation = 0;
|
|
1212
|
+
static RECONCILIATION_INTERVAL = 30;
|
|
1213
|
+
sockets = /* @__PURE__ */ new Set();
|
|
1214
|
+
attachments = /* @__PURE__ */ new WeakMap();
|
|
1215
|
+
snapshotSubscribers = /* @__PURE__ */ new Set();
|
|
1216
|
+
broadcastFullState() {
|
|
1217
|
+
if (!this.stateInitialized || this.cachedState === null) return;
|
|
1218
|
+
const stateMsg = JSON.stringify({
|
|
1219
|
+
type: "__state",
|
|
1220
|
+
state: this.cachedState,
|
|
1221
|
+
seq: this.seq,
|
|
1222
|
+
serverTime: Date.now()
|
|
249
1223
|
});
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
.description('保存済みの認証情報を削除')
|
|
256
|
-
.addOption(envOption())
|
|
257
|
-
.action(async (options) => {
|
|
258
|
-
const env = resolveEnv(options.env);
|
|
259
|
-
const removed = await clearCredentials(env);
|
|
260
|
-
if (removed) {
|
|
261
|
-
console.log(`ログアウトしました (env: ${env})`);
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
console.log(`ログイン情報はありません (env: ${env})`);
|
|
1224
|
+
for (const peer of this.sockets) {
|
|
1225
|
+
try {
|
|
1226
|
+
peer.send(stateMsg);
|
|
1227
|
+
} catch {
|
|
1228
|
+
}
|
|
265
1229
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
1230
|
+
this.notifySnapshotSubscribers();
|
|
1231
|
+
}
|
|
1232
|
+
notifySnapshotSubscribers() {
|
|
1233
|
+
this.snapshotSubscribers.forEach((cb) => {
|
|
1234
|
+
try {
|
|
1235
|
+
cb(this.cachedState);
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
console.warn("[SyncRoom] snapshot subscriber threw:", err);
|
|
1238
|
+
}
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
handleConnection(ws, url) {
|
|
1242
|
+
const playerId = url.searchParams.get("playerId");
|
|
1243
|
+
if (!playerId) {
|
|
1244
|
+
ws.close(1008, "Missing required query parameter: playerId");
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
const connectionId = randomUUID2();
|
|
1248
|
+
console.log(`[SyncRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId}`);
|
|
1249
|
+
this.sockets.add(ws);
|
|
1250
|
+
this.attachments.set(ws, { connectionId, playerId });
|
|
1251
|
+
ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
|
|
1252
|
+
ws.on("close", () => this.handleClose(ws));
|
|
1253
|
+
ws.on("error", () => this.handleClose(ws));
|
|
1254
|
+
ws.send(JSON.stringify({ type: "__room_init", myId: playerId }));
|
|
1255
|
+
if (this.stateInitialized && this.cachedState !== null) {
|
|
1256
|
+
ws.send(
|
|
1257
|
+
JSON.stringify({
|
|
1258
|
+
type: "__state",
|
|
1259
|
+
state: this.cachedState,
|
|
1260
|
+
seq: this.seq,
|
|
1261
|
+
serverTime: Date.now()
|
|
1262
|
+
})
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
handleMessage(ws, msg) {
|
|
1267
|
+
if (msg === "__ping") {
|
|
1268
|
+
try {
|
|
1269
|
+
ws.send("__pong");
|
|
1270
|
+
} catch {
|
|
1271
|
+
}
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const attachment = this.attachments.get(ws);
|
|
1275
|
+
if (!attachment) return;
|
|
1276
|
+
const senderId = attachment.playerId;
|
|
1277
|
+
let parsed;
|
|
272
1278
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
1279
|
+
parsed = JSON.parse(msg);
|
|
1280
|
+
} catch {
|
|
1281
|
+
return;
|
|
275
1282
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
1283
|
+
const msgType = parsed.type;
|
|
1284
|
+
console.log(`[SyncRoom] \u2B05 recv from=${senderId} type=${msgType}`);
|
|
1285
|
+
if (msgType === "__init_state") {
|
|
1286
|
+
if (this.stateInitialized) {
|
|
1287
|
+
console.log(`[SyncRoom] __init_state skipped (already initialized)`);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
this.stateInitialized = true;
|
|
1291
|
+
this.cachedState = parsed.state;
|
|
1292
|
+
this.seq = 0;
|
|
1293
|
+
this.patchesSinceReconciliation = 0;
|
|
1294
|
+
console.log(`[SyncRoom] \u2705 State initialized`);
|
|
1295
|
+
this.broadcastFullState();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (msgType === "__clear_state") {
|
|
1299
|
+
this.cachedState = null;
|
|
1300
|
+
this.stateInitialized = false;
|
|
1301
|
+
this.seq = 0;
|
|
1302
|
+
this.patchesSinceReconciliation = 0;
|
|
1303
|
+
console.log(`[SyncRoom] \u{1F5D1} State cleared`);
|
|
1304
|
+
const clearedMsg = JSON.stringify({ type: "__state_cleared" });
|
|
1305
|
+
for (const peer of this.sockets) {
|
|
1306
|
+
try {
|
|
1307
|
+
peer.send(clearedMsg);
|
|
1308
|
+
} catch {
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
this.notifySnapshotSubscribers();
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (msgType === "__request_state") {
|
|
1315
|
+
if (!this.stateInitialized || this.cachedState === null) return;
|
|
1316
|
+
try {
|
|
1317
|
+
ws.send(
|
|
1318
|
+
JSON.stringify({
|
|
1319
|
+
type: "__state",
|
|
1320
|
+
state: this.cachedState,
|
|
1321
|
+
seq: this.seq,
|
|
1322
|
+
serverTime: Date.now()
|
|
1323
|
+
})
|
|
1324
|
+
);
|
|
1325
|
+
} catch {
|
|
1326
|
+
}
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (msgType === "__patch") {
|
|
1330
|
+
if (!this.stateInitialized || this.cachedState === null) return;
|
|
1331
|
+
const ops = parsed.ops;
|
|
1332
|
+
if (!ops || !Array.isArray(ops) || ops.length === 0) return;
|
|
1333
|
+
const serverTime = Date.now();
|
|
1334
|
+
resolveServerTime(ops, serverTime);
|
|
1335
|
+
const workingCopy = structuredClone(this.cachedState);
|
|
1336
|
+
const ok = applyPatch(workingCopy, ops);
|
|
1337
|
+
if (!ok) {
|
|
1338
|
+
try {
|
|
1339
|
+
ws.send(JSON.stringify({ type: "__patch_failed", reason: "apply_error" }));
|
|
1340
|
+
ws.send(
|
|
1341
|
+
JSON.stringify({
|
|
1342
|
+
type: "__state",
|
|
1343
|
+
state: this.cachedState,
|
|
1344
|
+
seq: this.seq,
|
|
1345
|
+
serverTime: Date.now()
|
|
1346
|
+
})
|
|
1347
|
+
);
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
this.cachedState = workingCopy;
|
|
1353
|
+
this.seq++;
|
|
1354
|
+
const ackMsg = JSON.stringify({
|
|
1355
|
+
type: "__patch_ack",
|
|
1356
|
+
ops,
|
|
1357
|
+
seq: this.seq,
|
|
1358
|
+
serverTime,
|
|
1359
|
+
senderId
|
|
1360
|
+
});
|
|
1361
|
+
for (const peer of this.sockets) {
|
|
1362
|
+
try {
|
|
1363
|
+
peer.send(ackMsg);
|
|
1364
|
+
} catch {
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
this.notifySnapshotSubscribers();
|
|
1368
|
+
this.patchesSinceReconciliation++;
|
|
1369
|
+
if (this.patchesSinceReconciliation >= _SyncRoom.RECONCILIATION_INTERVAL) {
|
|
1370
|
+
this.patchesSinceReconciliation = 0;
|
|
1371
|
+
this.broadcastFullState();
|
|
1372
|
+
}
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const outData = JSON.stringify({ ...parsed, __from: senderId });
|
|
1376
|
+
if (parsed.__to && typeof parsed.__to === "string") {
|
|
1377
|
+
for (const peer of this.sockets) {
|
|
1378
|
+
const pa = this.attachments.get(peer);
|
|
1379
|
+
if (pa && pa.playerId === parsed.__to) {
|
|
1380
|
+
try {
|
|
1381
|
+
peer.send(outData);
|
|
1382
|
+
} catch {
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
} else {
|
|
1387
|
+
for (const peer of this.sockets) {
|
|
1388
|
+
if (peer === ws) continue;
|
|
1389
|
+
try {
|
|
1390
|
+
peer.send(outData);
|
|
1391
|
+
} catch {
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
handleClose(ws) {
|
|
1397
|
+
const attachment = this.attachments.get(ws);
|
|
1398
|
+
if (attachment) {
|
|
1399
|
+
console.log(
|
|
1400
|
+
`[SyncRoom] \u274C Disconnected: connectionId=${attachment.connectionId} playerId=${attachment.playerId}`
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
this.sockets.delete(ws);
|
|
1404
|
+
this.attachments.delete(ws);
|
|
1405
|
+
}
|
|
1406
|
+
// ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
|
|
1407
|
+
admin() {
|
|
1408
|
+
return {
|
|
1409
|
+
getSnapshot: () => this.cachedState,
|
|
1410
|
+
getRawState: () => this.cachedState,
|
|
1411
|
+
setRawState: (next) => {
|
|
1412
|
+
this.cachedState = next;
|
|
1413
|
+
this.stateInitialized = true;
|
|
1414
|
+
this.broadcastFullState();
|
|
1415
|
+
},
|
|
1416
|
+
mergeRawState: (patch) => {
|
|
1417
|
+
if (!this.cachedState) return;
|
|
1418
|
+
applyJsonMergePatch(this.cachedState, patch);
|
|
1419
|
+
this.broadcastFullState();
|
|
1420
|
+
},
|
|
1421
|
+
patchRawState: (ops) => {
|
|
1422
|
+
if (!this.cachedState) return;
|
|
1423
|
+
applyJsonPatch(this.cachedState, ops);
|
|
1424
|
+
this.broadcastFullState();
|
|
1425
|
+
},
|
|
1426
|
+
subscribeSnapshot: (cb) => {
|
|
1427
|
+
this.snapshotSubscribers.add(cb);
|
|
1428
|
+
return () => {
|
|
1429
|
+
this.snapshotSubscribers.delete(cb);
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
// src/dev-server/relay-room.ts
|
|
1437
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1438
|
+
var RelayRoom = class {
|
|
1439
|
+
sockets = /* @__PURE__ */ new Set();
|
|
1440
|
+
attachments = /* @__PURE__ */ new WeakMap();
|
|
1441
|
+
handleConnection(ws, url) {
|
|
1442
|
+
const playerId = url.searchParams.get("playerId");
|
|
1443
|
+
if (!playerId) {
|
|
1444
|
+
ws.close(1008, "Missing required query parameter: playerId");
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
const connectionId = randomUUID3();
|
|
1448
|
+
console.log(`[RelayRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId}`);
|
|
1449
|
+
this.sockets.add(ws);
|
|
1450
|
+
this.attachments.set(ws, { connectionId, playerId });
|
|
1451
|
+
ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
|
|
1452
|
+
ws.on("close", () => this.handleClose(ws));
|
|
1453
|
+
ws.on("error", () => this.handleClose(ws));
|
|
1454
|
+
ws.send(JSON.stringify({ type: "__room_init", myId: playerId }));
|
|
1455
|
+
}
|
|
1456
|
+
handleMessage(ws, msg) {
|
|
1457
|
+
if (msg === "__ping") {
|
|
1458
|
+
try {
|
|
1459
|
+
ws.send("__pong");
|
|
1460
|
+
} catch {
|
|
1461
|
+
}
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const attachment = this.attachments.get(ws);
|
|
1465
|
+
if (!attachment) return;
|
|
1466
|
+
const senderId = attachment.playerId;
|
|
1467
|
+
let parsed;
|
|
1468
|
+
try {
|
|
1469
|
+
parsed = JSON.parse(msg);
|
|
1470
|
+
} catch {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
console.log(`[RelayRoom] \u2B05 recv from=${senderId}`, JSON.stringify(parsed));
|
|
1474
|
+
const outData = JSON.stringify({ ...parsed, __from: senderId });
|
|
1475
|
+
if (parsed.__to && typeof parsed.__to === "string") {
|
|
1476
|
+
for (const peer of this.sockets) {
|
|
1477
|
+
const pa = this.attachments.get(peer);
|
|
1478
|
+
if (pa && pa.playerId === parsed.__to) {
|
|
1479
|
+
try {
|
|
1480
|
+
peer.send(outData);
|
|
1481
|
+
} catch {
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
} else {
|
|
1486
|
+
for (const peer of this.sockets) {
|
|
1487
|
+
if (peer === ws) continue;
|
|
1488
|
+
try {
|
|
1489
|
+
peer.send(outData);
|
|
1490
|
+
} catch {
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
handleClose(ws) {
|
|
1496
|
+
const attachment = this.attachments.get(ws);
|
|
1497
|
+
if (attachment) {
|
|
1498
|
+
console.log(`[RelayRoom] \u274C Disconnected: connectionId=${attachment.connectionId}`);
|
|
279
1499
|
}
|
|
1500
|
+
this.sockets.delete(ws);
|
|
1501
|
+
this.attachments.delete(ws);
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
|
|
1505
|
+
// src/dev-server/server.ts
|
|
1506
|
+
var CONTENT_TYPES = {
|
|
1507
|
+
html: "text/html; charset=utf-8",
|
|
1508
|
+
js: "application/javascript; charset=utf-8",
|
|
1509
|
+
json: "application/json; charset=utf-8"
|
|
280
1510
|
};
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1511
|
+
function startHarnessServer(opts) {
|
|
1512
|
+
const gameRooms = /* @__PURE__ */ new Map();
|
|
1513
|
+
const syncRooms = /* @__PURE__ */ new Map();
|
|
1514
|
+
const relayRooms = /* @__PURE__ */ new Map();
|
|
1515
|
+
const resolveAdmin = () => {
|
|
1516
|
+
const preferredKey = `${opts.meta.revisionId}/${opts.meta.roomKey}`;
|
|
1517
|
+
const gameRoom = gameRooms.get(preferredKey) ?? gameRooms.values().next().value;
|
|
1518
|
+
if (gameRoom) return { kind: "game", game: gameRoom.admin() };
|
|
1519
|
+
const syncRoom = syncRooms.values().next().value;
|
|
1520
|
+
if (syncRoom) return { kind: "sync", sync: syncRoom.admin() };
|
|
1521
|
+
return null;
|
|
1522
|
+
};
|
|
1523
|
+
const httpServer = createServer((req, res) => {
|
|
1524
|
+
handleHttpRequest(req, res, opts);
|
|
1525
|
+
});
|
|
1526
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1527
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
1528
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1529
|
+
const pathname = url.pathname;
|
|
1530
|
+
const gameMatch = pathname.match(/^\/ws\/games\/([^/]+)\/([^/]+)$/);
|
|
1531
|
+
if (gameMatch) {
|
|
1532
|
+
const revisionId = gameMatch[1];
|
|
1533
|
+
const roomId = gameMatch[2];
|
|
1534
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1535
|
+
if (!opts.logic) {
|
|
1536
|
+
ws.close(1008, "No server logic loaded (missing serverActionLogicPath in manifest)");
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const key = `${revisionId}/${roomId}`;
|
|
1540
|
+
let room = gameRooms.get(key);
|
|
1541
|
+
if (!room) {
|
|
1542
|
+
room = new GameRoom(opts.logic, opts.meta.players);
|
|
1543
|
+
gameRooms.set(key, room);
|
|
1544
|
+
}
|
|
1545
|
+
room.handleConnection(ws, url);
|
|
1546
|
+
});
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const syncMatch = pathname.match(/^\/ws\/sync\/([^/]+)$/);
|
|
1550
|
+
if (syncMatch) {
|
|
1551
|
+
const roomId = syncMatch[1];
|
|
1552
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1553
|
+
let room = syncRooms.get(roomId);
|
|
1554
|
+
if (!room) {
|
|
1555
|
+
room = new SyncRoom();
|
|
1556
|
+
syncRooms.set(roomId, room);
|
|
1557
|
+
}
|
|
1558
|
+
room.handleConnection(ws, url);
|
|
1559
|
+
});
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const relayMatch = pathname.match(/^\/ws\/rooms\/([^/]+)$/);
|
|
1563
|
+
if (relayMatch) {
|
|
1564
|
+
const roomId = relayMatch[1];
|
|
1565
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1566
|
+
let room = relayRooms.get(roomId);
|
|
1567
|
+
if (!room) {
|
|
1568
|
+
room = new RelayRoom();
|
|
1569
|
+
relayRooms.set(roomId, room);
|
|
1570
|
+
}
|
|
1571
|
+
room.handleConnection(ws, url);
|
|
1572
|
+
});
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (pathname === "/dev/admin") {
|
|
1576
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1577
|
+
handleAdminConnection(ws, resolveAdmin);
|
|
1578
|
+
});
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
proxyUpgradeToScenario(req, socket, head, opts.meta.scenarioUrl);
|
|
1582
|
+
});
|
|
1583
|
+
httpServer.listen(opts.port, opts.host ?? "127.0.0.1");
|
|
1584
|
+
return {
|
|
1585
|
+
stop: () => new Promise((resolve6) => {
|
|
1586
|
+
for (const ws of wss.clients) {
|
|
1587
|
+
try {
|
|
1588
|
+
ws.close();
|
|
1589
|
+
} catch {
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
wss.close(() => {
|
|
1593
|
+
httpServer.close(() => resolve6());
|
|
1594
|
+
});
|
|
1595
|
+
})
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
function currentLanHosts() {
|
|
1599
|
+
const hosts = [];
|
|
1600
|
+
const name = hostname();
|
|
1601
|
+
if (name)
|
|
1602
|
+
hosts.push(
|
|
1603
|
+
name.toLowerCase().endsWith(".local") ? name.toLowerCase() : `${name.toLowerCase()}.local`
|
|
1604
|
+
);
|
|
1605
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
1606
|
+
for (const entry of entries ?? []) {
|
|
1607
|
+
if (entry.family === "IPv4" && !entry.internal) hosts.push(entry.address);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
return hosts;
|
|
1611
|
+
}
|
|
1612
|
+
function handleHttpRequest(req, res, opts) {
|
|
1613
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1614
|
+
const pathname = url.pathname;
|
|
1615
|
+
const writeText = (body, contentType, status = 200) => {
|
|
1616
|
+
res.writeHead(status, {
|
|
1617
|
+
"content-type": contentType,
|
|
1618
|
+
"cache-control": "no-store",
|
|
1619
|
+
"access-control-allow-origin": "*"
|
|
297
1620
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
1621
|
+
res.end(body);
|
|
1622
|
+
};
|
|
1623
|
+
const isScenarioIndex = (pathname === "/" || pathname === "/index.html") && url.searchParams.has("server");
|
|
1624
|
+
if ((pathname === "/" || pathname === "/index.html") && !isScenarioIndex) {
|
|
1625
|
+
writeText(opts.harness.html, CONTENT_TYPES.html);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (pathname === "/_uzu_harness.js") {
|
|
1629
|
+
writeText(opts.harness.js, CONTENT_TYPES.js);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (pathname === "/_uzu_meta.json") {
|
|
1633
|
+
writeText(JSON.stringify({ ...opts.meta, lanHosts: currentLanHosts() }), CONTENT_TYPES.json);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
if (pathname === "/health") {
|
|
1637
|
+
writeText(JSON.stringify({ ok: true }), CONTENT_TYPES.json);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
proxyHttpToScenario(req, res, opts.meta.scenarioUrl);
|
|
1641
|
+
}
|
|
1642
|
+
function handleAdminConnection(ws, resolveAdmin) {
|
|
1643
|
+
const unsubs = /* @__PURE__ */ new Map();
|
|
1644
|
+
ws.on("message", async (raw) => {
|
|
1645
|
+
let msg;
|
|
1646
|
+
try {
|
|
1647
|
+
msg = JSON.parse(raw.toString());
|
|
1648
|
+
} catch {
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
const type = msg.type;
|
|
1652
|
+
const id = msg.id;
|
|
1653
|
+
const admin = resolveAdmin();
|
|
1654
|
+
if (!admin) {
|
|
1655
|
+
ws.send(JSON.stringify({ type: "error", id, error: "no_active_room" }));
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
if (type === "call") {
|
|
1659
|
+
const method = msg.method;
|
|
1660
|
+
const args = msg.args ?? [];
|
|
1661
|
+
try {
|
|
1662
|
+
const value = await invokeAdminMethod(admin, method, args);
|
|
1663
|
+
if (value === void 0 && !adminHasMethod(admin, method)) {
|
|
1664
|
+
ws.send(JSON.stringify({ type: "missing", id }));
|
|
1665
|
+
} else {
|
|
1666
|
+
ws.send(JSON.stringify({ type: "result", id, value }));
|
|
309
1667
|
}
|
|
310
|
-
|
|
311
|
-
|
|
1668
|
+
} catch (err) {
|
|
1669
|
+
ws.send(
|
|
1670
|
+
JSON.stringify({
|
|
1671
|
+
type: "error",
|
|
1672
|
+
id,
|
|
1673
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1674
|
+
})
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (type === "subscribe") {
|
|
1680
|
+
const kind = msg.kind;
|
|
1681
|
+
const unsub = subscribeAdmin(admin, kind, (value) => {
|
|
1682
|
+
try {
|
|
1683
|
+
ws.send(JSON.stringify({ type: "event", id, value }));
|
|
1684
|
+
} catch {
|
|
312
1685
|
}
|
|
1686
|
+
});
|
|
1687
|
+
if (!unsub) {
|
|
1688
|
+
ws.send(JSON.stringify({ type: "error", id, error: "subscription_not_supported" }));
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
unsubs.set(id, unsub);
|
|
1692
|
+
ws.send(JSON.stringify({ type: "result", id, value: null }));
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
if (type === "unsubscribe") {
|
|
1696
|
+
const unsub = unsubs.get(id);
|
|
1697
|
+
if (unsub) {
|
|
1698
|
+
unsub();
|
|
1699
|
+
unsubs.delete(id);
|
|
1700
|
+
}
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
ws.on("close", () => {
|
|
1705
|
+
unsubs.forEach((unsub) => unsub());
|
|
1706
|
+
unsubs.clear();
|
|
1707
|
+
});
|
|
1708
|
+
}
|
|
1709
|
+
function adminHasMethod(admin, method) {
|
|
1710
|
+
if (admin.kind === "game" && admin.game) {
|
|
1711
|
+
return typeof admin.game[method] === "function";
|
|
1712
|
+
}
|
|
1713
|
+
if (admin.kind === "sync" && admin.sync) {
|
|
1714
|
+
return typeof admin.sync[method] === "function";
|
|
1715
|
+
}
|
|
1716
|
+
return false;
|
|
1717
|
+
}
|
|
1718
|
+
async function invokeAdminMethod(admin, method, args) {
|
|
1719
|
+
const target = admin.kind === "game" ? admin.game : admin.sync;
|
|
1720
|
+
if (!target) return void 0;
|
|
1721
|
+
const fn = target[method];
|
|
1722
|
+
if (typeof fn !== "function") return void 0;
|
|
1723
|
+
return await fn.apply(target, args);
|
|
1724
|
+
}
|
|
1725
|
+
function subscribeAdmin(admin, kind, cb) {
|
|
1726
|
+
if (kind === "snapshot") {
|
|
1727
|
+
const target = admin.kind === "game" ? admin.game : admin.sync;
|
|
1728
|
+
if (!target) return null;
|
|
1729
|
+
return target.subscribeSnapshot(cb);
|
|
1730
|
+
}
|
|
1731
|
+
if (kind === "events") {
|
|
1732
|
+
if (admin.kind !== "game" || !admin.game) return null;
|
|
1733
|
+
return admin.game.subscribeEvents(cb);
|
|
1734
|
+
}
|
|
1735
|
+
return null;
|
|
1736
|
+
}
|
|
1737
|
+
var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
|
|
1738
|
+
"connection",
|
|
1739
|
+
"keep-alive",
|
|
1740
|
+
"proxy-authenticate",
|
|
1741
|
+
"proxy-authorization",
|
|
1742
|
+
"te",
|
|
1743
|
+
"trailer",
|
|
1744
|
+
"transfer-encoding",
|
|
1745
|
+
"upgrade"
|
|
1746
|
+
]);
|
|
1747
|
+
function stripHopByHopHeaders(headers) {
|
|
1748
|
+
const out = {};
|
|
1749
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1750
|
+
if (!HOP_BY_HOP_HEADERS.has(name.toLowerCase())) out[name] = value;
|
|
1751
|
+
}
|
|
1752
|
+
return out;
|
|
1753
|
+
}
|
|
1754
|
+
var scenarioProxyAgent = new Agent({ keepAlive: true, maxSockets: 32 });
|
|
1755
|
+
function proxyHttpToScenario(req, res, scenarioUrl) {
|
|
1756
|
+
const target = new URL(scenarioUrl);
|
|
1757
|
+
const proxyReq = httpRequest(
|
|
1758
|
+
{
|
|
1759
|
+
agent: scenarioProxyAgent,
|
|
1760
|
+
hostname: target.hostname,
|
|
1761
|
+
port: target.port,
|
|
1762
|
+
path: req.url,
|
|
1763
|
+
method: req.method,
|
|
1764
|
+
headers: { ...stripHopByHopHeaders(req.headers), host: target.host },
|
|
1765
|
+
timeout: 3e4
|
|
1766
|
+
},
|
|
1767
|
+
(proxyRes) => {
|
|
1768
|
+
res.writeHead(proxyRes.statusCode ?? 502, stripHopByHopHeaders(proxyRes.headers));
|
|
1769
|
+
proxyRes.pipe(res);
|
|
1770
|
+
}
|
|
1771
|
+
);
|
|
1772
|
+
const fail = () => {
|
|
1773
|
+
if (!res.headersSent) {
|
|
1774
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
1775
|
+
}
|
|
1776
|
+
res.end("scenario dev server unreachable");
|
|
1777
|
+
};
|
|
1778
|
+
proxyReq.on("timeout", () => proxyReq.destroy(new Error("proxy timeout")));
|
|
1779
|
+
proxyReq.on("error", fail);
|
|
1780
|
+
res.on("close", () => proxyReq.destroy());
|
|
1781
|
+
req.pipe(proxyReq);
|
|
1782
|
+
}
|
|
1783
|
+
function proxyUpgradeToScenario(req, socket, head, scenarioUrl) {
|
|
1784
|
+
const target = new URL(scenarioUrl);
|
|
1785
|
+
const proxySocket = netConnect(Number(target.port), target.hostname, () => {
|
|
1786
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
1787
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
1788
|
+
const name = req.rawHeaders[i];
|
|
1789
|
+
const value = name.toLowerCase() === "host" ? target.host : req.rawHeaders[i + 1];
|
|
1790
|
+
lines.push(`${name}: ${value}`);
|
|
1791
|
+
}
|
|
1792
|
+
proxySocket.write(lines.join("\r\n") + "\r\n\r\n");
|
|
1793
|
+
if (head.length) proxySocket.write(head);
|
|
1794
|
+
proxySocket.pipe(socket);
|
|
1795
|
+
socket.pipe(proxySocket);
|
|
1796
|
+
});
|
|
1797
|
+
const destroyBoth = () => {
|
|
1798
|
+
proxySocket.destroy();
|
|
1799
|
+
socket.destroy();
|
|
1800
|
+
};
|
|
1801
|
+
proxySocket.on("error", destroyBoth);
|
|
1802
|
+
socket.on("error", destroyBoth);
|
|
1803
|
+
proxySocket.on("close", destroyBoth);
|
|
1804
|
+
socket.on("close", destroyBoth);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// src/dev-server/load-logic.ts
|
|
1808
|
+
import { build as build2 } from "esbuild";
|
|
1809
|
+
import { existsSync, mkdtempSync, rmSync } from "fs";
|
|
1810
|
+
import { tmpdir } from "os";
|
|
1811
|
+
import { dirname as dirname2, join as join4, resolve as resolve2 } from "path";
|
|
1812
|
+
import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
|
|
1813
|
+
function resolveSdkShimPath() {
|
|
1814
|
+
const here = fileURLToPath2(import.meta.url);
|
|
1815
|
+
const shim = here.endsWith(".ts") ? resolve2(dirname2(here), "sdk-server-shim.ts") : resolve2(dirname2(here), "dev-server", "sdk-server-shim.js");
|
|
1816
|
+
if (!existsSync(shim)) {
|
|
1817
|
+
throw new Error(`sdk-server-shim \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${shim}`);
|
|
1818
|
+
}
|
|
1819
|
+
return shim;
|
|
1820
|
+
}
|
|
1821
|
+
var uzuSdkServerStub = {
|
|
1822
|
+
name: "uzu-sdk-server-stub",
|
|
1823
|
+
setup(build3) {
|
|
1824
|
+
const shimPath = resolveSdkShimPath();
|
|
1825
|
+
build3.onResolve({ filter: /^(@uzuhq\/code-sdk|@uzupj\/uzu-sdk)$/ }, () => ({
|
|
1826
|
+
path: shimPath
|
|
1827
|
+
}));
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
async function loadLogicFromPath(logicPath) {
|
|
1831
|
+
const absPath = resolve2(logicPath);
|
|
1832
|
+
const tempDir = mkdtempSync(join4(tmpdir(), "uzu-dev-logic-"));
|
|
1833
|
+
const outPath = join4(tempDir, "logic.mjs");
|
|
1834
|
+
await build2({
|
|
1835
|
+
entryPoints: [absPath],
|
|
1836
|
+
outfile: outPath,
|
|
1837
|
+
bundle: true,
|
|
1838
|
+
format: "esm",
|
|
1839
|
+
target: "es2022",
|
|
1840
|
+
platform: "neutral",
|
|
1841
|
+
plugins: [uzuSdkServerStub]
|
|
1842
|
+
});
|
|
1843
|
+
const mod = await import(pathToFileURL(outPath).href);
|
|
1844
|
+
const logic = mod.default ?? mod.logic;
|
|
1845
|
+
if (!logic) {
|
|
1846
|
+
throw new Error(
|
|
1847
|
+
`${logicPath} must export a GameLogic object.
|
|
1848
|
+
Use either: export default logic
|
|
1849
|
+
Or: export const logic: GameLogic<State> = { ... }`
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
return {
|
|
1853
|
+
logic,
|
|
1854
|
+
dispose: () => {
|
|
1855
|
+
try {
|
|
1856
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
1857
|
+
} catch {
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
// src/harness/page.ts
|
|
1864
|
+
function harnessHtml() {
|
|
1865
|
+
return `<!DOCTYPE html>
|
|
1866
|
+
<html lang="ja">
|
|
1867
|
+
<head>
|
|
1868
|
+
<meta charset="utf-8" />
|
|
1869
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
1870
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
1871
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
1872
|
+
<meta name="apple-mobile-web-app-title" content="UZU dev" />
|
|
1873
|
+
<title>UZU dev harness</title>
|
|
1874
|
+
<style>
|
|
1875
|
+
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #1a1a2e; overflow: hidden; }
|
|
1876
|
+
</style>
|
|
1877
|
+
</head>
|
|
1878
|
+
<body>
|
|
1879
|
+
<script src="/_uzu_harness.js"></script>
|
|
1880
|
+
</body>
|
|
1881
|
+
</html>
|
|
1882
|
+
`;
|
|
1883
|
+
}
|
|
1884
|
+
async function buildHarnessClientJs(entryFile) {
|
|
1885
|
+
const esbuild = await import("esbuild");
|
|
1886
|
+
const result = await esbuild.build({
|
|
1887
|
+
entryPoints: [entryFile],
|
|
1888
|
+
bundle: true,
|
|
1889
|
+
format: "iife",
|
|
1890
|
+
target: "es2022",
|
|
1891
|
+
platform: "browser",
|
|
1892
|
+
write: false,
|
|
1893
|
+
minify: false
|
|
1894
|
+
});
|
|
1895
|
+
const out = result.outputFiles?.[0];
|
|
1896
|
+
if (!out) {
|
|
1897
|
+
throw new Error("esbuild produced no output for harness client bundle");
|
|
1898
|
+
}
|
|
1899
|
+
return out.text;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// src/dev.ts
|
|
1903
|
+
var DEFAULT_DEV_COMMAND = "pnpm run dev";
|
|
1904
|
+
var DEFAULT_READY_PATTERN = "Local:\\s+(https?://[^\\s]+)";
|
|
1905
|
+
var DEFAULT_MIN_IFRAME_SHORT_EDGE = 360;
|
|
1906
|
+
async function runDevCommand() {
|
|
1907
|
+
const cwd = process.cwd();
|
|
1908
|
+
const manifestPath = resolve3(cwd, "manifest.json");
|
|
1909
|
+
if (!existsSync2(manifestPath)) {
|
|
1910
|
+
console.error("manifest.json \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002 scenario \u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u3067\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002");
|
|
1911
|
+
process.exit(1);
|
|
1912
|
+
}
|
|
1913
|
+
const manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
|
|
1914
|
+
const playerCount = manifest.characters?.length ?? manifest.playerCount ?? 2;
|
|
1915
|
+
const orientation = manifest.orientation ?? "portrait";
|
|
1916
|
+
const devCommand = manifest.dev?.command ?? DEFAULT_DEV_COMMAND;
|
|
1917
|
+
const readyPattern = new RegExp(manifest.dev?.readyPattern ?? DEFAULT_READY_PATTERN);
|
|
1918
|
+
let loaded = null;
|
|
1919
|
+
if (manifest.serverActionLogicPath) {
|
|
1920
|
+
console.log(`[uzu dev] Building server logic from ${manifest.serverActionLogicPath}...`);
|
|
1921
|
+
const logicPath = resolve3(cwd, manifest.serverActionLogicPath);
|
|
1922
|
+
loaded = await loadLogicFromPath(logicPath);
|
|
1923
|
+
console.log("[uzu dev] Server logic ready");
|
|
1924
|
+
}
|
|
1925
|
+
const thisDir = dirname3(fileURLToPath3(import.meta.url));
|
|
1926
|
+
const clientEntryJs = resolve3(thisDir, "harness", "client-entry.js");
|
|
1927
|
+
const clientEntryTs = resolve3(thisDir, "harness", "client-entry.ts");
|
|
1928
|
+
const clientEntry = existsSync2(clientEntryJs) ? clientEntryJs : existsSync2(clientEntryTs) ? clientEntryTs : null;
|
|
1929
|
+
if (!clientEntry) {
|
|
1930
|
+
console.error(`harness client entry \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${clientEntryJs}`);
|
|
1931
|
+
process.exit(1);
|
|
1932
|
+
}
|
|
1933
|
+
console.log("[uzu dev] Bundling harness client...");
|
|
1934
|
+
const harnessJs = await buildHarnessClientJs(clientEntry);
|
|
1935
|
+
console.log(`[uzu dev] Harness client ready (${harnessJs.length} bytes)`);
|
|
1936
|
+
console.log(`[uzu dev] Spawning: ${devCommand}`);
|
|
1937
|
+
const child = spawn(devCommand, {
|
|
1938
|
+
cwd,
|
|
1939
|
+
shell: true,
|
|
1940
|
+
env: { ...process.env, FORCE_COLOR: "1" },
|
|
1941
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1942
|
+
});
|
|
1943
|
+
const scenarioUrl = await watchForReady(child, readyPattern);
|
|
1944
|
+
console.log(`[uzu dev] Scenario ready at ${scenarioUrl}`);
|
|
1945
|
+
const roomKey = "devroom";
|
|
1946
|
+
const harnessPort = await findFreePort();
|
|
1947
|
+
const lanHosts = currentLanHosts();
|
|
1948
|
+
const players = Array.from({ length: playerCount }, (_, i) => ({
|
|
1949
|
+
id: `dev_${i}`,
|
|
1950
|
+
nickname: manifest.characters?.[i]?.name ?? `Player ${i + 1}`,
|
|
1951
|
+
iconUrl: manifest.characters?.[i]?.icon ?? "",
|
|
1952
|
+
characterId: manifest.characters?.[i]?.id
|
|
1953
|
+
}));
|
|
1954
|
+
const admin = manifest.admin ?? false;
|
|
1955
|
+
const spectator = manifest.spectator ?? false;
|
|
1956
|
+
const meta = {
|
|
1957
|
+
scenarioUrl,
|
|
1958
|
+
playerCount,
|
|
1959
|
+
orientation,
|
|
1960
|
+
players,
|
|
1961
|
+
admin,
|
|
1962
|
+
spectator,
|
|
1963
|
+
roomKey,
|
|
1964
|
+
serverBaseUrl: `ws://localhost:${harnessPort}`,
|
|
1965
|
+
revisionId: "dev",
|
|
1966
|
+
devMinIframeShortEdge: DEFAULT_MIN_IFRAME_SHORT_EDGE
|
|
1967
|
+
};
|
|
1968
|
+
const server = startHarnessServer({
|
|
1969
|
+
port: harnessPort,
|
|
1970
|
+
// 同一 LAN の実機 (スマホ等) から harness / 単一プレイヤー画面を開けるよう
|
|
1971
|
+
// 全 interface で listen する。 GameRoom は in-memory の dev 専用 state のみ。
|
|
1972
|
+
host: HARNESS_HOST,
|
|
1973
|
+
logic: loaded?.logic ?? null,
|
|
1974
|
+
harness: { html: harnessHtml(), js: harnessJs },
|
|
1975
|
+
meta
|
|
1976
|
+
});
|
|
1977
|
+
const harnessUrl = `http://localhost:${harnessPort}/`;
|
|
1978
|
+
console.log("");
|
|
1979
|
+
console.log(` \u{1F3AE} UZU dev harness: ${harnessUrl}`);
|
|
1980
|
+
console.log(` scenario: ${scenarioUrl}`);
|
|
1981
|
+
console.log(` players: ${playerCount} orientation: ${orientation} roomKey: ${roomKey}`);
|
|
1982
|
+
const observerIds = [...spectator ? ["spec_0"] : [], ...admin ? ["admin_0"] : []];
|
|
1983
|
+
if (observerIds.length > 0) {
|
|
1984
|
+
console.log(` observer seats (roster \u5916): ${observerIds.join(", ")}`);
|
|
1985
|
+
}
|
|
1986
|
+
if (lanHosts.length) {
|
|
1987
|
+
console.log("");
|
|
1988
|
+
console.log(` \u{1F4F1} \u540C\u4E00 Wi-Fi \u306E\u5B9F\u6A5F\u304B\u3089 (LAN \u306B\u516C\u958B\u3055\u308C\u3066\u3044\u307E\u3059):`);
|
|
1989
|
+
for (const host of lanHosts) {
|
|
1990
|
+
console.log(` http://${host}:${harnessPort}/ (5\u4EBA\u30B0\u30EA\u30C3\u30C9)`);
|
|
1991
|
+
console.log(
|
|
1992
|
+
` http://${host}:${harnessPort}/?player=0 (Player 1 \u5358\u4F53, ?player=0..${playerCount - 1})`
|
|
1993
|
+
);
|
|
1994
|
+
}
|
|
1995
|
+
console.log(` \u203B scenario \u3078\u306E\u901A\u4FE1\u306F harness \u304C proxy \u3059\u308B\u305F\u3081 vite \u5074\u306E\u8A2D\u5B9A\u306F\u4E0D\u8981\u3067\u3059`);
|
|
1996
|
+
}
|
|
1997
|
+
console.log("");
|
|
1998
|
+
const cleanup = async () => {
|
|
1999
|
+
console.log("\n[uzu dev] Shutting down...");
|
|
2000
|
+
try {
|
|
2001
|
+
await server.stop();
|
|
2002
|
+
} catch {
|
|
2003
|
+
}
|
|
2004
|
+
if (child.pid && !child.killed) {
|
|
2005
|
+
child.kill("SIGTERM");
|
|
2006
|
+
}
|
|
2007
|
+
if (loaded) loaded.dispose();
|
|
2008
|
+
};
|
|
2009
|
+
process.on("SIGINT", () => {
|
|
2010
|
+
void cleanup().then(() => process.exit(0));
|
|
2011
|
+
});
|
|
2012
|
+
process.on("SIGTERM", () => {
|
|
2013
|
+
void cleanup().then(() => process.exit(0));
|
|
2014
|
+
});
|
|
2015
|
+
child.on("exit", (code) => {
|
|
2016
|
+
console.log(`[uzu dev] scenario dev server exited (code=${code})`);
|
|
2017
|
+
void cleanup().then(() => process.exit(code ?? 0));
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
function stripAnsi(s) {
|
|
2021
|
+
return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
2022
|
+
}
|
|
2023
|
+
function watchForReady(child, pattern) {
|
|
2024
|
+
return new Promise((resolve6, reject) => {
|
|
2025
|
+
const timeout = setTimeout(() => {
|
|
2026
|
+
reject(new Error("Timed out waiting for scenario dev server to become ready (30s)"));
|
|
2027
|
+
}, 3e4);
|
|
2028
|
+
let resolved = false;
|
|
2029
|
+
const onLine = (line) => {
|
|
2030
|
+
process.stdout.write(line + "\n");
|
|
2031
|
+
if (resolved) return;
|
|
2032
|
+
const clean = stripAnsi(line);
|
|
2033
|
+
const m = pattern.exec(clean);
|
|
2034
|
+
if (!m) return;
|
|
2035
|
+
const captured = m[1];
|
|
2036
|
+
if (!captured) return;
|
|
2037
|
+
const url = /^https?:\/\//i.test(captured) ? captured : `http://localhost:${captured}`;
|
|
2038
|
+
resolved = true;
|
|
2039
|
+
clearTimeout(timeout);
|
|
2040
|
+
resolve6(url.replace(/\/$/, ""));
|
|
2041
|
+
};
|
|
2042
|
+
if (child.stdout) {
|
|
2043
|
+
const rl = createInterface({ input: child.stdout });
|
|
2044
|
+
rl.on("line", onLine);
|
|
2045
|
+
}
|
|
2046
|
+
if (child.stderr) {
|
|
2047
|
+
const rl = createInterface({ input: child.stderr });
|
|
2048
|
+
rl.on("line", onLine);
|
|
2049
|
+
}
|
|
2050
|
+
child.on("error", (err) => {
|
|
2051
|
+
clearTimeout(timeout);
|
|
2052
|
+
reject(err);
|
|
2053
|
+
});
|
|
2054
|
+
child.on("exit", (code) => {
|
|
2055
|
+
if (!resolved) {
|
|
2056
|
+
clearTimeout(timeout);
|
|
2057
|
+
reject(new Error(`scenario dev server exited before ready (code=${code})`));
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
var DEFAULT_HARNESS_PORT = 10001;
|
|
2063
|
+
var MAX_HARNESS_PORT_ATTEMPTS = 100;
|
|
2064
|
+
var HARNESS_HOST = "0.0.0.0";
|
|
2065
|
+
async function findFreePort() {
|
|
2066
|
+
const net = await import("net");
|
|
2067
|
+
const tryPort = (port) => new Promise((resolve6) => {
|
|
2068
|
+
const server = net.createServer();
|
|
2069
|
+
server.unref();
|
|
2070
|
+
server.once("error", () => resolve6(null));
|
|
2071
|
+
server.listen(port, HARNESS_HOST, () => {
|
|
2072
|
+
const address = server.address();
|
|
2073
|
+
const assigned = typeof address === "object" && address !== null ? address.port : port;
|
|
2074
|
+
server.close(() => resolve6(assigned));
|
|
2075
|
+
});
|
|
2076
|
+
});
|
|
2077
|
+
for (let i = 0; i < MAX_HARNESS_PORT_ATTEMPTS; i++) {
|
|
2078
|
+
const candidate = DEFAULT_HARNESS_PORT + i;
|
|
2079
|
+
const ok2 = await tryPort(candidate);
|
|
2080
|
+
if (ok2 != null) return ok2;
|
|
2081
|
+
}
|
|
2082
|
+
const ok = await tryPort(0);
|
|
2083
|
+
if (ok != null) return ok;
|
|
2084
|
+
throw new Error("could not determine free port");
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/sdk-version.ts
|
|
2088
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
|
|
2089
|
+
import { dirname as dirname4, resolve as resolve4 } from "path";
|
|
2090
|
+
var SDK_PACKAGE_PATHS = [
|
|
2091
|
+
["@uzuhq", "code-sdk"],
|
|
2092
|
+
["@uzupj", "uzu-sdk"]
|
|
2093
|
+
];
|
|
2094
|
+
var readInstalledSdkVersion = (cwd) => {
|
|
2095
|
+
const pkgPath = findSdkPackageJson(cwd);
|
|
2096
|
+
if (!pkgPath) {
|
|
2097
|
+
throw new Error(
|
|
2098
|
+
"@uzuhq/code-sdk \u304C node_modules \u306B\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002\u4F9D\u5B58\u3092 install \u3057\u3066\u304B\u3089 publish \u3057\u3066\u304F\u3060\u3055\u3044\u3002"
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
const version = JSON.parse(readFileSync5(pkgPath, "utf-8")).version;
|
|
2102
|
+
if (!version) {
|
|
2103
|
+
throw new Error(`${pkgPath} \u306B version \u304C\u3042\u308A\u307E\u305B\u3093\u3002`);
|
|
2104
|
+
}
|
|
2105
|
+
return version;
|
|
2106
|
+
};
|
|
2107
|
+
var findSdkPackageJson = (cwd) => {
|
|
2108
|
+
let dir = resolve4(cwd);
|
|
2109
|
+
for (; ; ) {
|
|
2110
|
+
const found = SDK_PACKAGE_PATHS.map(
|
|
2111
|
+
(segments) => resolve4(dir, "node_modules", ...segments, "package.json")
|
|
2112
|
+
).find(existsSync3);
|
|
2113
|
+
if (found) {
|
|
2114
|
+
return found;
|
|
2115
|
+
}
|
|
2116
|
+
const parent = dirname4(dir);
|
|
2117
|
+
if (parent === dir) {
|
|
2118
|
+
return void 0;
|
|
2119
|
+
}
|
|
2120
|
+
dir = parent;
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
// src/auth/browser.ts
|
|
2125
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2126
|
+
var openBrowser = (url) => {
|
|
2127
|
+
const platform = process.platform;
|
|
2128
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
2129
|
+
const args = platform === "win32" ? ["/c", "start", '""', url] : [url];
|
|
2130
|
+
try {
|
|
2131
|
+
const child = spawn2(cmd, args, { detached: true, stdio: "ignore" });
|
|
2132
|
+
child.unref();
|
|
2133
|
+
child.on("error", () => {
|
|
2134
|
+
});
|
|
2135
|
+
} catch {
|
|
2136
|
+
}
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2139
|
+
// src/auth/jwt.ts
|
|
2140
|
+
var parseIdTokenClaims = (idToken) => {
|
|
2141
|
+
const parts = idToken.split(".");
|
|
2142
|
+
const payloadSegment = parts[1];
|
|
2143
|
+
if (parts.length !== 3 || payloadSegment === void 0) {
|
|
2144
|
+
throw new Error("id token is not a well-formed JWT");
|
|
2145
|
+
}
|
|
2146
|
+
let claims;
|
|
2147
|
+
try {
|
|
2148
|
+
claims = JSON.parse(Buffer.from(payloadSegment, "base64url").toString("utf8"));
|
|
2149
|
+
} catch {
|
|
2150
|
+
throw new Error("id token payload is not valid JSON");
|
|
2151
|
+
}
|
|
2152
|
+
const uid = claims.user_id || claims.sub || "";
|
|
2153
|
+
if (!uid) throw new Error("id token has no user_id or sub claim");
|
|
2154
|
+
return { uid, email: claims.email ?? "" };
|
|
2155
|
+
};
|
|
2156
|
+
|
|
2157
|
+
// src/auth/loopback.ts
|
|
2158
|
+
import { createServer as createServer2 } from "node:http";
|
|
2159
|
+
var SUCCESS_HTML = `<!doctype html><meta charset="utf-8"><title>uzu: Login successful</title>
|
|
2160
|
+
<body style="font-family:system-ui;max-width:480px;margin:80px auto;text-align:center;">
|
|
2161
|
+
<h2>Login successful</h2>
|
|
2162
|
+
<p>\u3053\u306E\u30BF\u30D6\u3092\u9589\u3058\u3066\u30BF\u30FC\u30DF\u30CA\u30EB\u306B\u623B\u3063\u3066\u304F\u3060\u3055\u3044\u3002</p>
|
|
2163
|
+
</body>`;
|
|
2164
|
+
var failureHtml = (msg) => {
|
|
2165
|
+
const escaped = msg.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
2166
|
+
return `<!doctype html><meta charset="utf-8"><title>uzu: Login failed</title>
|
|
2167
|
+
<body style="font-family:system-ui;max-width:480px;margin:80px auto;text-align:center;">
|
|
2168
|
+
<h2>Login failed</h2>
|
|
2169
|
+
<p>${escaped}</p>
|
|
2170
|
+
</body>`;
|
|
2171
|
+
};
|
|
2172
|
+
var startLoopbackServer = async () => {
|
|
2173
|
+
let resolve6 = null;
|
|
2174
|
+
let reject = null;
|
|
2175
|
+
let delivered = false;
|
|
2176
|
+
let pending = null;
|
|
2177
|
+
const server = createServer2((req, res) => {
|
|
2178
|
+
if (!req.url?.startsWith("/callback")) {
|
|
2179
|
+
res.writeHead(404);
|
|
2180
|
+
res.end();
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
if (delivered) {
|
|
2184
|
+
res.writeHead(410, { "Content-Type": "text/plain" });
|
|
2185
|
+
res.end("already handled");
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
delivered = true;
|
|
2189
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
2190
|
+
const err = url.searchParams.get("error");
|
|
2191
|
+
const code = url.searchParams.get("code");
|
|
2192
|
+
if (err) {
|
|
2193
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
2194
|
+
res.end(failureHtml(`auth server returned error: ${err}`));
|
|
2195
|
+
const e = new Error(`auth server returned error: ${err}`);
|
|
2196
|
+
if (reject) reject(e);
|
|
2197
|
+
else pending = { err: e };
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
if (!code) {
|
|
2201
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
2202
|
+
res.end(failureHtml("missing authorization code"));
|
|
2203
|
+
const e = new Error("authorization code missing in callback");
|
|
2204
|
+
if (reject) reject(e);
|
|
2205
|
+
else pending = { err: e };
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2209
|
+
res.end(SUCCESS_HTML);
|
|
2210
|
+
if (resolve6) resolve6(code);
|
|
2211
|
+
else pending = { code };
|
|
2212
|
+
});
|
|
2213
|
+
await new Promise((res, rej) => {
|
|
2214
|
+
server.once("error", rej);
|
|
2215
|
+
server.listen(0, "127.0.0.1", () => res());
|
|
2216
|
+
});
|
|
2217
|
+
const port = server.address().port;
|
|
2218
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
2219
|
+
return {
|
|
2220
|
+
port,
|
|
2221
|
+
redirectUri,
|
|
2222
|
+
waitForCode: (timeoutMs) => new Promise((res, rej) => {
|
|
2223
|
+
if (pending) {
|
|
2224
|
+
if ("code" in pending) res(pending.code);
|
|
2225
|
+
else rej(pending.err);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const t = setTimeout(
|
|
2229
|
+
() => rej(new Error(`login timed out after ${timeoutMs}ms`)),
|
|
2230
|
+
timeoutMs
|
|
2231
|
+
);
|
|
2232
|
+
resolve6 = (code) => {
|
|
2233
|
+
clearTimeout(t);
|
|
2234
|
+
res(code);
|
|
2235
|
+
};
|
|
2236
|
+
reject = (err) => {
|
|
2237
|
+
clearTimeout(t);
|
|
2238
|
+
rej(err);
|
|
2239
|
+
};
|
|
2240
|
+
}),
|
|
2241
|
+
close: () => server.close()
|
|
2242
|
+
};
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
// src/auth/pkce.ts
|
|
2246
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2247
|
+
var base64url = (buf) => buf.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
2248
|
+
var generateCodeVerifier = () => base64url(randomBytes(32));
|
|
2249
|
+
var computeCodeChallenge = (verifier) => base64url(createHash("sha256").update(verifier).digest());
|
|
2250
|
+
|
|
2251
|
+
// src/auth/login-flow.ts
|
|
2252
|
+
var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2253
|
+
var runLoginFlow = async (env, onURL) => {
|
|
2254
|
+
const verifier = generateCodeVerifier();
|
|
2255
|
+
const challenge = computeCodeChallenge(verifier);
|
|
2256
|
+
const server = await startLoopbackServer();
|
|
2257
|
+
try {
|
|
2258
|
+
const baseUrl = authBaseURL(env);
|
|
2259
|
+
const loginUrl = buildLoginURL(baseUrl, server.redirectUri, challenge);
|
|
2260
|
+
onURL?.(loginUrl);
|
|
2261
|
+
openBrowser(loginUrl);
|
|
2262
|
+
const code = await server.waitForCode(LOGIN_TIMEOUT_MS);
|
|
2263
|
+
const tokens = await exchangeCodeForTokens(baseUrl, code, verifier, server.redirectUri);
|
|
2264
|
+
const { uid, email } = parseIdTokenClaims(tokens.accessToken);
|
|
2265
|
+
return {
|
|
2266
|
+
email,
|
|
2267
|
+
uid,
|
|
2268
|
+
refreshToken: tokens.refreshToken,
|
|
2269
|
+
idToken: tokens.accessToken,
|
|
2270
|
+
expiresIn: tokens.expiresIn
|
|
2271
|
+
};
|
|
2272
|
+
} finally {
|
|
2273
|
+
server.close();
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
|
|
2277
|
+
// src/cli.ts
|
|
2278
|
+
var envOption = () => new Option("--env <env>", "\u63A5\u7D9A\u5148\u74B0\u5883 (dev | stg | prd)").default(DEFAULT_ENV).hideHelp();
|
|
2279
|
+
var readOwnCliVersion = () => {
|
|
2280
|
+
const pkgPath = resolve5(dirname5(fileURLToPath4(import.meta.url)), "..", "package.json");
|
|
2281
|
+
const version = JSON.parse(readFileSync6(pkgPath, "utf-8")).version;
|
|
2282
|
+
if (!version) {
|
|
2283
|
+
throw new Error("uzu-cli \u306E package.json \u306B version \u304C\u3042\u308A\u307E\u305B\u3093\u3002");
|
|
2284
|
+
}
|
|
2285
|
+
return version;
|
|
2286
|
+
};
|
|
2287
|
+
var OWN_CLI_VERSION = readOwnCliVersion();
|
|
2288
|
+
var program = new Command();
|
|
2289
|
+
program.name("uzu").description("UZU \u30B2\u30FC\u30E0\u958B\u767A CLI").version(OWN_CLI_VERSION);
|
|
2290
|
+
program.command("create-2d-game").description("2D \u30A8\u30F3\u30B8\u30F3\u3092\u4F7F\u3063\u305F\u30B2\u30FC\u30E0\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u306E\u96DB\u5F62\u3092\u4F5C\u6210").argument("<name>", "\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u540D\uFF08\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u540D\uFF09").action((name) => {
|
|
2291
|
+
create2dGame(name);
|
|
2292
|
+
});
|
|
2293
|
+
program.command("dev").description(
|
|
2294
|
+
"scenario \u306E dev server \u3092\u8D77\u52D5\u3057\u3001 dev harness (iframe grid + HUD) \u3068 in-memory GameRoom / SyncRoom / RelayRoom \u3092\u63D0\u4F9B\u3059\u308B"
|
|
2295
|
+
).action(async () => {
|
|
2296
|
+
try {
|
|
2297
|
+
await runDevCommand();
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
console.error("[uzu dev]", err);
|
|
2300
|
+
process.exit(1);
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
program.command("publish").description("\u30B2\u30FC\u30E0\u3092\u30D3\u30EB\u30C9\u3057\u3066 R2 \u306B\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9 \u2192 \u767B\u9332").option("--change-notes <msg>", "\u30EA\u30D3\u30B8\u30E7\u30F3\u306E\u5909\u66F4\u30E1\u30E2", "").addOption(envOption()).action(async (options) => {
|
|
2304
|
+
const env = resolveEnv(options.env);
|
|
2305
|
+
const cwd = process.cwd();
|
|
2306
|
+
const manifestPath = resolve5(cwd, "manifest.json");
|
|
2307
|
+
if (!existsSync4(manifestPath)) {
|
|
2308
|
+
console.error("manifest.json \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093\u3002\u30B2\u30FC\u30E0\u30C7\u30A3\u30EC\u30AF\u30C8\u30EA\u3067\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002");
|
|
2309
|
+
process.exit(1);
|
|
2310
|
+
}
|
|
2311
|
+
const manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
|
|
2312
|
+
if (typeof manifest !== "object" || manifest === null) {
|
|
2313
|
+
console.error("manifest.json \u304C\u4E0D\u6B63\u306A\u5F62\u5F0F\u3067\u3059\u3002");
|
|
2314
|
+
process.exit(1);
|
|
2315
|
+
}
|
|
2316
|
+
if (!manifest.id || !manifest.playerCount || !manifest.output) {
|
|
2317
|
+
console.error("manifest.json \u306B id \u3068 playerCount \u3068 output \u304C\u5FC5\u8981\u3067\u3059\u3002");
|
|
2318
|
+
process.exit(1);
|
|
2319
|
+
}
|
|
2320
|
+
const gameId = manifest.id;
|
|
2321
|
+
const manifestCharacters = manifest.characters;
|
|
2322
|
+
if (manifestCharacters !== void 0) {
|
|
2323
|
+
if (manifestCharacters.length === 0) {
|
|
2324
|
+
console.error("characters \u304C\u7A7A\u306E\u914D\u5217\u3067\u3059\u3002");
|
|
2325
|
+
process.exit(1);
|
|
2326
|
+
}
|
|
2327
|
+
for (const c of manifestCharacters) {
|
|
2328
|
+
if (!c.id || !c.name) {
|
|
2329
|
+
console.error("characters \u306E\u5404\u8981\u7D20\u306B id \u3068 name \u304C\u5FC5\u8981\u3067\u3059\u3002");
|
|
2330
|
+
process.exit(1);
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
const playerCount = manifestCharacters?.length ?? manifest.playerCount;
|
|
2335
|
+
const rawOrientation = manifest.orientation ?? "portrait";
|
|
2336
|
+
if (rawOrientation !== "portrait" && rawOrientation !== "landscape") {
|
|
2337
|
+
console.error(
|
|
2338
|
+
`manifest.json \u306E orientation \u306F portrait \u304B landscape \u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044 (\u6307\u5B9A\u5024: ${String(rawOrientation)})`
|
|
2339
|
+
);
|
|
2340
|
+
process.exit(1);
|
|
2341
|
+
}
|
|
2342
|
+
const orientation = rawOrientation;
|
|
2343
|
+
const buildCommand = manifest.build;
|
|
2344
|
+
const outputDir = manifest.output;
|
|
2345
|
+
console.log(
|
|
2346
|
+
`Publishing game: ${gameId} (players: ${playerCount}, orientation: ${orientation})`
|
|
2347
|
+
);
|
|
2348
|
+
let sdkVersion;
|
|
2349
|
+
try {
|
|
2350
|
+
sdkVersion = readInstalledSdkVersion(cwd);
|
|
2351
|
+
} catch (e) {
|
|
2352
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
2353
|
+
process.exit(1);
|
|
2354
|
+
}
|
|
2355
|
+
await getValidIdToken(env).catch((e) => {
|
|
2356
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
2357
|
+
process.exit(1);
|
|
2358
|
+
});
|
|
2359
|
+
if (buildCommand) {
|
|
2360
|
+
console.log("Building...");
|
|
2361
|
+
execSync(buildCommand, { stdio: "inherit", cwd });
|
|
2362
|
+
}
|
|
2363
|
+
console.log("Creating ZIP...");
|
|
2364
|
+
const absoluteOutputDir = resolve5(cwd, outputDir);
|
|
2365
|
+
const zipPath = resolve5(cwd, "__zip__.zip");
|
|
2366
|
+
await new Promise((res, reject) => {
|
|
2367
|
+
const output = createWriteStream(zipPath);
|
|
2368
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
2369
|
+
output.on("close", () => res());
|
|
2370
|
+
archive.on("error", (err) => reject(err));
|
|
2371
|
+
archive.pipe(output);
|
|
2372
|
+
archive.directory(absoluteOutputDir, false);
|
|
2373
|
+
archive.finalize();
|
|
2374
|
+
});
|
|
2375
|
+
console.log(`Created: ${zipPath}`);
|
|
2376
|
+
console.log("Uploading to R2...");
|
|
2377
|
+
const { resourceId, revisionId, token } = await uploadGameToR2(env, zipPath, absoluteOutputDir);
|
|
2378
|
+
console.log(`Uploaded to R2. revisionId: ${revisionId}, resourceId: ${resourceId}`);
|
|
2379
|
+
if (manifest.serverActionLogicPath) {
|
|
2380
|
+
const logicEntryPoint = resolve5(cwd, manifest.serverActionLogicPath);
|
|
2381
|
+
const logicOutPath = resolve5(cwd, "__logic__.js");
|
|
2382
|
+
try {
|
|
2383
|
+
console.log("Building server logic...");
|
|
2384
|
+
await buildServerLogic(logicEntryPoint, logicOutPath);
|
|
2385
|
+
const logicModule = await import(pathToFileURL2(logicOutPath).href);
|
|
2386
|
+
const resolvedLogic = logicModule.default ?? logicModule.logic;
|
|
2387
|
+
if (!resolvedLogic) {
|
|
2388
|
+
console.error(
|
|
2389
|
+
`Error: ${manifest.serverActionLogicPath} must export a GameLogic object.
|
|
2390
|
+
Use either: export default logic
|
|
2391
|
+
Or: export const logic: GameLogic<State> = { ... }`
|
|
2392
|
+
);
|
|
2393
|
+
process.exit(1);
|
|
2394
|
+
}
|
|
2395
|
+
console.log("Uploading logic.js to R2...");
|
|
2396
|
+
const logicJsContent = readFileSync6(logicOutPath, "utf-8");
|
|
2397
|
+
await uploadLogicToR2(env, token, logicJsContent);
|
|
2398
|
+
} finally {
|
|
2399
|
+
if (existsSync4(logicOutPath)) unlinkSync(logicOutPath);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
let resolvedCharacters;
|
|
2403
|
+
if (manifestCharacters) {
|
|
2404
|
+
console.log("Resolving character icons...");
|
|
2405
|
+
const isLocalIcon = (char) => !!char.icon && !char.icon.startsWith("http://") && !char.icon.startsWith("https://");
|
|
2406
|
+
const localIconPaths = manifestCharacters.filter(isLocalIcon).map((char) => {
|
|
2407
|
+
const iconAbsPath = resolve5(cwd, char.icon);
|
|
2408
|
+
if (!existsSync4(iconAbsPath)) {
|
|
2409
|
+
console.error(`\u30A2\u30A4\u30B3\u30F3\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${char.icon}`);
|
|
2410
|
+
process.exit(1);
|
|
2411
|
+
}
|
|
2412
|
+
return iconAbsPath;
|
|
2413
|
+
});
|
|
2414
|
+
const iconURLs = await uploadIconsToCfImages(env, token, localIconPaths);
|
|
2415
|
+
resolvedCharacters = manifestCharacters.map((char) => {
|
|
2416
|
+
if (!isLocalIcon(char)) return char;
|
|
2417
|
+
const cfImagesUrl = iconURLs.get(resolve5(cwd, char.icon));
|
|
2418
|
+
if (!cfImagesUrl) {
|
|
2419
|
+
console.error(`\u30A2\u30A4\u30B3\u30F3\u306E\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u7D50\u679C\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${char.icon}`);
|
|
2420
|
+
process.exit(1);
|
|
2421
|
+
}
|
|
2422
|
+
console.log(`Uploaded icon: ${char.icon}`);
|
|
2423
|
+
return { ...char, icon: cfImagesUrl };
|
|
313
2424
|
});
|
|
2425
|
+
}
|
|
2426
|
+
console.log("Registering revision...");
|
|
2427
|
+
const revId = await registerRevision({
|
|
2428
|
+
env,
|
|
2429
|
+
gameId,
|
|
2430
|
+
resourceId,
|
|
2431
|
+
uploadToken: token,
|
|
2432
|
+
changeNotes: options.changeNotes || "",
|
|
2433
|
+
playerCount,
|
|
2434
|
+
orientation,
|
|
2435
|
+
characters: resolvedCharacters,
|
|
2436
|
+
manifest,
|
|
2437
|
+
publishMeta: { sdkVersion, cliVersion: OWN_CLI_VERSION }
|
|
2438
|
+
});
|
|
2439
|
+
console.log(`Registered revision: ${revId}`);
|
|
2440
|
+
unlinkSync(zipPath);
|
|
2441
|
+
const studioUrl = `https://${studioHost(env)}/ja/scenarios/global-id/${gameId}`;
|
|
2442
|
+
console.log("\nDone!");
|
|
2443
|
+
console.log(`UZU Studio: ${studioUrl}`);
|
|
314
2444
|
});
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
console.log(`失効しました: ${id} (env: ${env})`);
|
|
2445
|
+
program.command("login").description("UZU \u306B\u30ED\u30B0\u30A4\u30F3\u3057\u3066 publish \u7528\u306E\u8A8D\u8A3C\u60C5\u5831\u3092\u4FDD\u5B58").addOption(envOption()).action(async (options) => {
|
|
2446
|
+
const env = resolveEnv(options.env);
|
|
2447
|
+
let result;
|
|
2448
|
+
try {
|
|
2449
|
+
result = await runLoginFlow(env, (url) => {
|
|
2450
|
+
console.log("\u30D6\u30E9\u30A6\u30B6\u3067 Google \u30ED\u30B0\u30A4\u30F3\u3092\u958B\u304D\u307E\u3059\u3002");
|
|
2451
|
+
console.log("\u81EA\u52D5\u3067\u958B\u304B\u306A\u3044\u5834\u5408\u306F\u6B21\u306E URL \u3092\u8E0F\u3093\u3067\u304F\u3060\u3055\u3044:");
|
|
2452
|
+
console.log(` ${url}`);
|
|
324
2453
|
});
|
|
2454
|
+
} catch (e) {
|
|
2455
|
+
console.error(`\u30ED\u30B0\u30A4\u30F3\u306B\u5931\u6557\u3057\u307E\u3057\u305F: ${e.message}`);
|
|
2456
|
+
process.exit(1);
|
|
2457
|
+
}
|
|
2458
|
+
await saveCredentials(env, {
|
|
2459
|
+
uid: result.uid,
|
|
2460
|
+
email: result.email,
|
|
2461
|
+
refreshToken: result.refreshToken,
|
|
2462
|
+
loggedInAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2463
|
+
});
|
|
2464
|
+
console.log(`
|
|
2465
|
+
\u30ED\u30B0\u30A4\u30F3\u3057\u307E\u3057\u305F: ${result.email} (env: ${env})`);
|
|
2466
|
+
console.log(`\u4FDD\u5B58\u5148: ${credentialsPath()} (mode 0600)`);
|
|
2467
|
+
});
|
|
2468
|
+
program.command("logout").description("\u4FDD\u5B58\u6E08\u307F\u306E\u8A8D\u8A3C\u60C5\u5831\u3092\u524A\u9664").addOption(envOption()).action(async (options) => {
|
|
2469
|
+
const env = resolveEnv(options.env);
|
|
2470
|
+
const removed = await clearCredentials(env);
|
|
2471
|
+
if (removed) {
|
|
2472
|
+
console.log(`\u30ED\u30B0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F (env: ${env})`);
|
|
2473
|
+
} else {
|
|
2474
|
+
console.log(`\u30ED\u30B0\u30A4\u30F3\u60C5\u5831\u306F\u3042\u308A\u307E\u305B\u3093 (env: ${env})`);
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
var tokenCommand = program.command("token").description("CI \u7528 publish token \u306E\u7BA1\u7406");
|
|
2478
|
+
var runTokenCommand = async (rawEnv, fn) => {
|
|
2479
|
+
const env = resolveEnv(rawEnv);
|
|
2480
|
+
try {
|
|
2481
|
+
const idToken = await getLoginIdToken(env);
|
|
2482
|
+
await fn({ env, baseUrl: authBaseURL(env), idToken });
|
|
2483
|
+
} catch (e) {
|
|
2484
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
2485
|
+
process.exit(1);
|
|
2486
|
+
}
|
|
2487
|
+
};
|
|
2488
|
+
tokenCommand.command("create").description("publish token \u3092\u767A\u884C\u3059\u308B (\u5E73\u6587\u306F\u3053\u306E 1 \u56DE\u3057\u304B\u8868\u793A\u3055\u308C\u306A\u3044)").option("--name <label>", "\u30C8\u30FC\u30AF\u30F3\u306E\u7528\u9014\u304C\u308F\u304B\u308B\u8868\u793A\u540D", "").addOption(envOption()).action(async (options) => {
|
|
2489
|
+
await runTokenCommand(options.env, async ({ env, baseUrl, idToken }) => {
|
|
2490
|
+
const issued = await createPublishToken(baseUrl, idToken, options.name);
|
|
2491
|
+
console.log(`publish token \u3092\u767A\u884C\u3057\u307E\u3057\u305F (env: ${env})`);
|
|
2492
|
+
console.log(` id: ${issued.id}`);
|
|
2493
|
+
console.log(` name: ${issued.name || "(\u306A\u3057)"}`);
|
|
2494
|
+
console.log("");
|
|
2495
|
+
console.log(` ${issued.token}`);
|
|
2496
|
+
console.log("");
|
|
2497
|
+
console.log("\u3053\u306E\u5E73\u6587\u306F\u518D\u8868\u793A\u3067\u304D\u307E\u305B\u3093\u3002CI \u3067\u306F secret \u306B\u767B\u9332\u3057\u3001");
|
|
2498
|
+
console.log(`${PUBLISH_TOKEN_ENV} \u3068\u3057\u3066\u6E21\u3057\u3066\u304F\u3060\u3055\u3044\u3002`);
|
|
2499
|
+
});
|
|
2500
|
+
});
|
|
2501
|
+
tokenCommand.command("list").description("\u6709\u52B9\u306A publish token \u3092\u4E00\u89A7\u3059\u308B").addOption(envOption()).action(async (options) => {
|
|
2502
|
+
await runTokenCommand(options.env, async ({ env, baseUrl, idToken }) => {
|
|
2503
|
+
const tokens = await listPublishTokens(baseUrl, idToken);
|
|
2504
|
+
if (tokens.length === 0) {
|
|
2505
|
+
console.log(`publish token \u306F\u3042\u308A\u307E\u305B\u3093 (env: ${env})`);
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
for (const t of tokens) {
|
|
2509
|
+
console.log(
|
|
2510
|
+
`${t.id} ${t.name || "(\u306A\u3057)"} created=${t.createdAt} lastUsed=${t.lastUsedAt ?? "-"}`
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
});
|
|
2514
|
+
});
|
|
2515
|
+
tokenCommand.command("revoke").description("publish token \u3092\u5931\u52B9\u3055\u305B\u308B").argument("<id>", "\u5931\u52B9\u3055\u305B\u308B token \u306E id (uzu token list \u3067\u78BA\u8A8D)").addOption(envOption()).action(async (id, options) => {
|
|
2516
|
+
await runTokenCommand(options.env, async ({ env, baseUrl, idToken }) => {
|
|
2517
|
+
await revokePublishToken(baseUrl, idToken, id);
|
|
2518
|
+
console.log(`\u5931\u52B9\u3057\u307E\u3057\u305F: ${id} (env: ${env})`);
|
|
2519
|
+
});
|
|
325
2520
|
});
|
|
326
2521
|
program.parse();
|