@uzuhq/code-cli 0.5.7 → 0.5.9

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