@uzuhq/code-cli 0.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/auth/browser.js +22 -0
  4. package/dist/auth/config.js +48 -0
  5. package/dist/auth/env.js +42 -0
  6. package/dist/auth/jwt.js +27 -0
  7. package/dist/auth/login-flow.js +44 -0
  8. package/dist/auth/loopback.js +94 -0
  9. package/dist/auth/pkce.js +11 -0
  10. package/dist/auth/publish-token.js +74 -0
  11. package/dist/auth/token-cache.js +70 -0
  12. package/dist/auth/uzu-auth.js +94 -0
  13. package/dist/build-server-logic.js +28 -0
  14. package/dist/cf-images-upload.js +52 -0
  15. package/dist/cli.js +303 -0
  16. package/dist/create-2d-game.js +56 -0
  17. package/dist/dev-server/game-room.js +436 -0
  18. package/dist/dev-server/game-types.js +11 -0
  19. package/dist/dev-server/json-patch.js +114 -0
  20. package/dist/dev-server/load-logic.js +70 -0
  21. package/dist/dev-server/random.js +35 -0
  22. package/dist/dev-server/relay-room.js +84 -0
  23. package/dist/dev-server/server.js +367 -0
  24. package/dist/dev-server/sync-room.js +268 -0
  25. package/dist/dev.js +235 -0
  26. package/dist/harness/admin-client.js +215 -0
  27. package/dist/harness/client-entry.js +90 -0
  28. package/dist/harness/dev-button.js +249 -0
  29. package/dist/harness/mount.js +664 -0
  30. package/dist/harness/page.js +46 -0
  31. package/dist/r2-upload.js +93 -0
  32. package/dist/rest-register.js +50 -0
  33. package/dist/upload-session.js +71 -0
  34. package/game-2d-template/index.html.tpl +18 -0
  35. package/game-2d-template/manifest.json.tpl +6 -0
  36. package/game-2d-template/package.json.tpl +23 -0
  37. package/game-2d-template/src/main.ts +49 -0
  38. package/game-2d-template/src/vite-env.d.ts +1 -0
  39. package/game-2d-template/tsconfig.json +12 -0
  40. package/game-2d-template/vite.config.ts +6 -0
  41. package/package.json +43 -0
@@ -0,0 +1,436 @@
1
+ /**
2
+ * dev-server 版 GameRoom (ServerAction / run() モード用)。
3
+ *
4
+ * v3-play-server の `game-worker-template/game-room.ts` (Cloudflare Worker DO 版)
5
+ * と同一プロトコル (`__game_start` / `__tick` / `__tick_delta` / `__action_result` /
6
+ * `__action_result_delta` / `__state` / `__room_init` / `__action_error`) を喋る
7
+ * Node.js 版。 SDK 側の `runOnlineServerAction` client がそのまま接続できる。
8
+ *
9
+ * 本番との差分:
10
+ * - Durable Object storage / hibernation なし (in-memory only)。CLI restart で state 消失。
11
+ * - WebSocketPair / acceptWebSocket → `ws` package の raw WebSocket + Set 管理
12
+ * - setWebSocketAutoResponse (__ping/__pong) → message handler で直接返す
13
+ */
14
+ import { randomUUID } from 'crypto';
15
+ import { compare, applyJsonMergePatch, applyJsonPatch } from './json-patch.js';
16
+ import { SeededRandomImpl } from './random.js';
17
+ export class GameRoom {
18
+ logic;
19
+ tickRate;
20
+ gameState = null;
21
+ stateInitialized = false;
22
+ random = null;
23
+ seed = 0;
24
+ tickCount = 0;
25
+ tickTimer = null;
26
+ tickPaused = false;
27
+ playerInputs = {};
28
+ /**
29
+ * roster (kind 付き)。 spectator / admin 席も含む — logic.setup にはこれを渡す。
30
+ * dev harness では manifest から組んだ roster を constructor で注入する
31
+ * (server 権威)。 その場合、 接続クエリの roster 申告は一切採用しないので、
32
+ * 旧世代 harness page の残タブが reconnect しても roster を汚染できない。
33
+ * 本番 DO は backend 由来の roster を全 client が同一申告するため接続時
34
+ * 登録で成立している — 権威が platform 側にある点は同じ。
35
+ */
36
+ seats = [];
37
+ seq = 0;
38
+ prevBroadcastState = null;
39
+ static SNAPSHOT_INTERVAL = 20;
40
+ sockets = new Set();
41
+ attachments = new WeakMap();
42
+ snapshotSubscribers = new Set();
43
+ eventSubscribers = new Set();
44
+ constructor(logic, seats) {
45
+ this.logic = logic;
46
+ this.tickRate = logic.tickRate ?? 0;
47
+ if (seats && seats.length > 0) {
48
+ this.seats = seats;
49
+ console.log(`[GameRoom] 📋 Seats (server-authoritative): ${seats
50
+ .map((p) => `${p.id}(${p.kind ?? 'player'})`)
51
+ .join(', ')}`);
52
+ }
53
+ }
54
+ // ─── Broadcast ─────────────────────────────────────────
55
+ broadcastAll(msg) {
56
+ const data = JSON.stringify(msg);
57
+ for (const ws of this.sockets) {
58
+ try {
59
+ ws.send(data);
60
+ }
61
+ catch {
62
+ /* disconnected */
63
+ }
64
+ }
65
+ }
66
+ sendTo(ws, msg) {
67
+ try {
68
+ ws.send(JSON.stringify(msg));
69
+ }
70
+ catch {
71
+ /* disconnected */
72
+ }
73
+ }
74
+ broadcastStateDelta(events, extra) {
75
+ this.seq++;
76
+ const fullType = extra.ack !== undefined ? '__action_result' : '__tick';
77
+ const deltaType = extra.ack !== undefined ? '__action_result_delta' : '__tick_delta';
78
+ const needFull = this.prevBroadcastState === null || this.seq % GameRoom.SNAPSHOT_INTERVAL === 0;
79
+ if (needFull) {
80
+ this.broadcastAll({
81
+ type: fullType,
82
+ state: this.gameState,
83
+ events,
84
+ seq: this.seq,
85
+ ...extra,
86
+ });
87
+ }
88
+ else {
89
+ const patches = compare(this.prevBroadcastState, this.gameState);
90
+ const deltaPayload = JSON.stringify({
91
+ type: deltaType,
92
+ patches,
93
+ events,
94
+ seq: this.seq,
95
+ ...extra,
96
+ });
97
+ const fullPayload = JSON.stringify({
98
+ type: fullType,
99
+ state: this.gameState,
100
+ events,
101
+ seq: this.seq,
102
+ ...extra,
103
+ });
104
+ const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
105
+ for (const ws of this.sockets) {
106
+ try {
107
+ ws.send(data);
108
+ }
109
+ catch {
110
+ /* disconnected */
111
+ }
112
+ }
113
+ }
114
+ this.prevBroadcastState = structuredClone(this.gameState);
115
+ this.notifySnapshotSubscribers();
116
+ if (events.length > 0)
117
+ this.notifyEventSubscribers(events);
118
+ }
119
+ notifySnapshotSubscribers() {
120
+ this.snapshotSubscribers.forEach((cb) => {
121
+ try {
122
+ cb(this.gameState);
123
+ }
124
+ catch (err) {
125
+ console.warn('[GameRoom] snapshot subscriber threw:', err);
126
+ }
127
+ });
128
+ }
129
+ notifyEventSubscribers(events) {
130
+ const frozen = Object.freeze(events.slice());
131
+ this.eventSubscribers.forEach((cb) => {
132
+ try {
133
+ cb(frozen);
134
+ }
135
+ catch (err) {
136
+ console.warn('[GameRoom] event subscriber threw:', err);
137
+ }
138
+ });
139
+ }
140
+ // ─── Game Lifecycle ────────────────────────────────────
141
+ maybeStartGame() {
142
+ if (this.gameState !== null)
143
+ return;
144
+ if (this.seats.length === 0)
145
+ return;
146
+ this.seed = Date.now() & 0xffffffff;
147
+ this.random = new SeededRandomImpl(this.seed);
148
+ this.gameState = this.logic.setup(this.seats, this.random);
149
+ this.stateInitialized = true;
150
+ this.tickCount = 0;
151
+ this.seq = 0;
152
+ this.prevBroadcastState = structuredClone(this.gameState);
153
+ console.log(`[GameRoom] ✅ Game started with ${this.seats.filter((p) => p.kind === 'player').length} players (${this.seats.length} seats)`);
154
+ this.broadcastAll({
155
+ type: '__game_start',
156
+ state: this.gameState,
157
+ seed: this.seed,
158
+ seq: 0,
159
+ });
160
+ if (this.tickRate > 0)
161
+ this.startTickLoop();
162
+ this.notifySnapshotSubscribers();
163
+ }
164
+ startTickLoop() {
165
+ if (this.tickTimer)
166
+ clearInterval(this.tickTimer);
167
+ this.tickTimer = setInterval(() => this.tick(), 1000 / this.tickRate);
168
+ }
169
+ // 全クライアント切断で tickTimer は止まる (handleClose) が gameState は残るため、
170
+ // 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
171
+ ensureTickLoop() {
172
+ if (this.tickRate > 0 &&
173
+ this.gameState !== null &&
174
+ this.sockets.size > 0 &&
175
+ !this.tickPaused &&
176
+ !this.tickTimer) {
177
+ this.startTickLoop();
178
+ }
179
+ }
180
+ tick() {
181
+ if (this.tickPaused)
182
+ return;
183
+ this.runOneTick();
184
+ }
185
+ runOneTick() {
186
+ if (!this.gameState || !this.random)
187
+ return;
188
+ const events = [];
189
+ const emit = (name, data) => events.push({ name, data: data ?? {} });
190
+ try {
191
+ this.logic.update(this.gameState, {
192
+ random: this.random,
193
+ tick: this.tickCount,
194
+ emit,
195
+ playerInputs: this.playerInputs,
196
+ });
197
+ }
198
+ catch (err) {
199
+ console.error(`[GameRoom] tick error at tick=${this.tickCount}:`, err);
200
+ this.tickCount++;
201
+ return;
202
+ }
203
+ this.tickCount++;
204
+ this.broadcastStateDelta(events, { tick: this.tickCount });
205
+ }
206
+ // ─── Connection ────────────────────────────────────────
207
+ handleConnection(ws, url) {
208
+ const playerId = url.searchParams.get('seatId');
209
+ if (!playerId) {
210
+ ws.close(1008, 'Missing required query parameter: seatId');
211
+ return;
212
+ }
213
+ const nickname = url.searchParams.get('nickname') ?? 'Guest';
214
+ const connectionId = randomUUID();
215
+ if (this.seats.length === 0) {
216
+ // constructor 注入 (server 権威) が無い場合のみ、 接続クエリの申告 roster に
217
+ // fallback する (本番 DO と同じ経路)。 dev harness では常に注入済みなので
218
+ // ここは通らない。
219
+ const rosterParam = url.searchParams.get('seats');
220
+ if (rosterParam) {
221
+ try {
222
+ const raw = JSON.parse(rosterParam);
223
+ if (Array.isArray(raw)) {
224
+ this.seats = raw.map((p) => ({
225
+ id: p.id,
226
+ nickname: p.name ?? 'Guest',
227
+ iconUrl: p.iconUrl ?? '',
228
+ characterId: p.characterId,
229
+ kind: p.kind,
230
+ }));
231
+ console.log(`[GameRoom] 📋 Seats registered: ${this.seats
232
+ .map((p) => `${p.id}(${p.kind})`)
233
+ .join(', ')}`);
234
+ }
235
+ }
236
+ catch {
237
+ /* ignore */
238
+ }
239
+ }
240
+ }
241
+ console.log(`[GameRoom] 🔗 New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`);
242
+ this.sockets.add(ws);
243
+ this.attachments.set(ws, { connectionId, playerId, nickname });
244
+ ws.on('message', (raw) => {
245
+ void this.handleMessage(ws, raw.toString());
246
+ });
247
+ ws.on('close', () => this.handleClose(ws));
248
+ ws.on('error', () => this.handleClose(ws));
249
+ this.sendTo(ws, {
250
+ type: '__room_init',
251
+ myId: playerId,
252
+ });
253
+ if (this.stateInitialized && this.gameState !== null) {
254
+ this.sendTo(ws, {
255
+ type: '__state',
256
+ state: this.gameState,
257
+ tick: this.tickCount,
258
+ seq: this.seq,
259
+ });
260
+ }
261
+ this.maybeStartGame();
262
+ // 既存ゲームへの再接続では maybeStartGame は early-return するので、
263
+ // 全切断で止まった tick loop をここで復旧する。
264
+ this.ensureTickLoop();
265
+ }
266
+ async handleMessage(ws, msg) {
267
+ const attachment = this.attachments.get(ws);
268
+ if (!attachment)
269
+ return;
270
+ const senderId = attachment.playerId;
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(msg);
274
+ }
275
+ catch {
276
+ return;
277
+ }
278
+ const msgType = parsed.type;
279
+ if (msgType === '__ping') {
280
+ this.sendTo(ws, { type: '__pong' });
281
+ return;
282
+ }
283
+ console.log(`[GameRoom] ⬅ recv from=${senderId} type=${msgType}`);
284
+ if (msgType === '__action') {
285
+ if (!this.gameState) {
286
+ this.sendTo(ws, {
287
+ type: '__action_error',
288
+ error: 'Game not started',
289
+ seq: parsed.seq,
290
+ });
291
+ return;
292
+ }
293
+ const actionName = parsed.action;
294
+ const payload = parsed.payload ?? {};
295
+ const seq = parsed.seq;
296
+ try {
297
+ await this.dispatchAction(actionName, payload, senderId, seq);
298
+ }
299
+ catch (err) {
300
+ this.sendTo(ws, {
301
+ type: '__action_error',
302
+ error: `Action failed: ${err instanceof Error ? err.message : 'unknown'}`,
303
+ seq,
304
+ });
305
+ }
306
+ return;
307
+ }
308
+ if (msgType === '__request_state') {
309
+ if (this.gameState !== null) {
310
+ this.sendTo(ws, {
311
+ type: '__state',
312
+ state: this.gameState,
313
+ tick: this.tickCount,
314
+ seq: this.seq,
315
+ });
316
+ }
317
+ return;
318
+ }
319
+ if (msgType === '__input') {
320
+ const inputData = parsed.data;
321
+ if (inputData)
322
+ this.playerInputs[senderId] = inputData;
323
+ return;
324
+ }
325
+ }
326
+ async dispatchAction(actionName, payload, senderId, ackSeq) {
327
+ const handler = this.logic.actions[actionName];
328
+ if (!handler) {
329
+ throw new Error(`Unknown action: ${actionName}`);
330
+ }
331
+ if (!this.gameState) {
332
+ throw new Error('Game not started');
333
+ }
334
+ const events = [];
335
+ const emit = (name, data) => events.push({ name, data: data ?? {} });
336
+ await handler(this.gameState, payload ?? {}, senderId, emit, { tick: this.tickCount });
337
+ this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
338
+ }
339
+ handleClose(ws) {
340
+ const attachment = this.attachments.get(ws);
341
+ if (!attachment)
342
+ return;
343
+ console.log(`[GameRoom] ❌ Disconnected: connectionId=${attachment.connectionId} playerId=${attachment.playerId}`);
344
+ this.sockets.delete(ws);
345
+ this.attachments.delete(ws);
346
+ delete this.playerInputs[attachment.playerId];
347
+ if (this.sockets.size === 0 && this.tickTimer) {
348
+ clearInterval(this.tickTimer);
349
+ this.tickTimer = null;
350
+ }
351
+ }
352
+ // ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
353
+ admin() {
354
+ return {
355
+ getSnapshot: () => this.gameState,
356
+ getRawState: () => this.gameState,
357
+ setRawState: (next) => {
358
+ this.gameState = next;
359
+ this.broadcastStateDelta([], { tick: this.tickCount });
360
+ },
361
+ mergeRawState: (patch) => {
362
+ if (!this.gameState)
363
+ return;
364
+ applyJsonMergePatch(this.gameState, patch);
365
+ this.broadcastStateDelta([], { tick: this.tickCount });
366
+ },
367
+ patchRawState: (ops) => {
368
+ if (!this.gameState)
369
+ return;
370
+ applyJsonPatch(this.gameState, ops);
371
+ this.broadcastStateDelta([], { tick: this.tickCount });
372
+ },
373
+ sendAction: async ({ as, type, payload }) => {
374
+ await this.dispatchAction(type, payload, as);
375
+ },
376
+ getSeed: () => this.seed,
377
+ pauseTick: () => {
378
+ if (this.tickRate <= 0)
379
+ return;
380
+ this.tickPaused = true;
381
+ },
382
+ resumeTick: () => {
383
+ if (this.tickRate <= 0)
384
+ return;
385
+ this.tickPaused = false;
386
+ },
387
+ stepTick: (n = 1) => {
388
+ if (this.tickRate <= 0)
389
+ return;
390
+ if (!Number.isInteger(n) || n < 0) {
391
+ throw new RangeError(`stepTick: n must be a non-negative integer (got ${n})`);
392
+ }
393
+ for (let i = 0; i < n; i++)
394
+ this.runOneTick();
395
+ },
396
+ getCurrentTick: () => this.tickCount,
397
+ isTickPaused: () => this.tickPaused,
398
+ reset: (opts) => {
399
+ if (opts?.seed === 'random') {
400
+ this.seed = Math.floor(Math.random() * 0xffffffff);
401
+ }
402
+ else if (typeof opts?.seed === 'number') {
403
+ this.seed = opts.seed;
404
+ }
405
+ this.random = new SeededRandomImpl(this.seed);
406
+ this.gameState = this.logic.setup(this.seats, this.random);
407
+ this.tickCount = 0;
408
+ // reset は新規ゲーム = 実行可能状態を意味するので pause も解除する。
409
+ this.tickPaused = false;
410
+ this.seq = 0;
411
+ this.prevBroadcastState = structuredClone(this.gameState);
412
+ this.broadcastAll({
413
+ type: '__game_start',
414
+ state: this.gameState,
415
+ seed: this.seed,
416
+ seq: 0,
417
+ });
418
+ this.notifySnapshotSubscribers();
419
+ // 全切断中に reset された後の再接続でも tick が回るよう復旧する。
420
+ this.ensureTickLoop();
421
+ },
422
+ subscribeSnapshot: (cb) => {
423
+ this.snapshotSubscribers.add(cb);
424
+ return () => {
425
+ this.snapshotSubscribers.delete(cb);
426
+ };
427
+ },
428
+ subscribeEvents: (cb) => {
429
+ this.eventSubscribers.add(cb);
430
+ return () => {
431
+ this.eventSubscribers.delete(cb);
432
+ };
433
+ },
434
+ };
435
+ }
436
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/play_screen_v3/connection-method/arch3-authority.md
4
+ *
5
+ * Node 側 dev-server 用の game type 定義。
6
+ * SDK `src/types.ts` および v3-play-server `game-worker-template/game-types.ts` と
7
+ * 同一 shape。 CLI は browser 依存 (window etc) を持たない Node 環境で走るので、
8
+ * SDK を直接 import できず、game-worker-template と同じく copy を持つ。
9
+ * 3 か所いずれかを変更したら他 2 か所も同期させること。
10
+ */
11
+ export {};
@@ -0,0 +1,114 @@
1
+ /**
2
+ * JSON Patch ユーティリティ。
3
+ * SDK `src/json-patch.ts` および v3-play-server `game-worker-template/json-patch.ts`
4
+ * と同一。 3 か所いずれかを変更したら他 2 か所も同期させること。
5
+ */
6
+ function escapePointer(key) {
7
+ return key.replace(/~/g, '~0').replace(/\//g, '~1');
8
+ }
9
+ function unescapePointer(token) {
10
+ return token.replace(/~1/g, '/').replace(/~0/g, '~');
11
+ }
12
+ export function compare(oldObj, newObj, basePath = '') {
13
+ if (oldObj === newObj)
14
+ return [];
15
+ if (oldObj === null ||
16
+ newObj === null ||
17
+ typeof oldObj !== 'object' ||
18
+ typeof newObj !== 'object') {
19
+ return [{ op: 'replace', path: basePath || '/', value: newObj }];
20
+ }
21
+ if (Array.isArray(oldObj) || Array.isArray(newObj)) {
22
+ if (JSON.stringify(oldObj) === JSON.stringify(newObj))
23
+ return [];
24
+ return [{ op: 'replace', path: basePath || '/', value: newObj }];
25
+ }
26
+ const ops = [];
27
+ const oldKeys = Object.keys(oldObj);
28
+ const newKeys = Object.keys(newObj);
29
+ for (const key of oldKeys) {
30
+ if (!(key in newObj)) {
31
+ ops.push({ op: 'remove', path: `${basePath}/${escapePointer(key)}` });
32
+ }
33
+ }
34
+ for (const key of newKeys) {
35
+ const childPath = `${basePath}/${escapePointer(key)}`;
36
+ if (!(key in oldObj)) {
37
+ ops.push({ op: 'add', path: childPath, value: newObj[key] });
38
+ }
39
+ else {
40
+ const childOps = compare(oldObj[key], newObj[key], childPath);
41
+ ops.push(...childOps);
42
+ }
43
+ }
44
+ return ops;
45
+ }
46
+ export function applyPatch(doc, ops) {
47
+ for (const op of ops) {
48
+ const tokens = op.path.split('/').slice(1).map(unescapePointer);
49
+ if (tokens.length === 0)
50
+ return false;
51
+ if (op.op === 'replace' || op.op === 'add') {
52
+ let target = doc;
53
+ for (let i = 0; i < tokens.length - 1; i++) {
54
+ target = target?.[tokens[i]];
55
+ if (target === undefined || target === null)
56
+ return false;
57
+ }
58
+ const lastKey = tokens[tokens.length - 1];
59
+ target[lastKey] = op.value;
60
+ }
61
+ else if (op.op === 'remove') {
62
+ let target = doc;
63
+ for (let i = 0; i < tokens.length - 1; i++) {
64
+ target = target?.[tokens[i]];
65
+ if (target === undefined || target === null)
66
+ return false;
67
+ }
68
+ const lastKey = tokens[tokens.length - 1];
69
+ if (Array.isArray(target)) {
70
+ target.splice(Number(lastKey), 1);
71
+ }
72
+ else {
73
+ delete target[lastKey];
74
+ }
75
+ }
76
+ }
77
+ return true;
78
+ }
79
+ const MERGE_PATCH_ARRAY_REJECT = '[applyJsonMergePatch] cannot merge a non-array patch into an array target';
80
+ export function applyJsonMergePatch(target, patch) {
81
+ if (patch === null || typeof patch !== 'object' || Array.isArray(patch))
82
+ return;
83
+ for (const [key, value] of Object.entries(patch)) {
84
+ if (value === undefined)
85
+ continue;
86
+ if (value === null) {
87
+ target[key] = null;
88
+ continue;
89
+ }
90
+ if (Array.isArray(value)) {
91
+ target[key] = value;
92
+ continue;
93
+ }
94
+ if (typeof value === 'object') {
95
+ const existing = target[key];
96
+ if (Array.isArray(existing)) {
97
+ throw new Error(MERGE_PATCH_ARRAY_REJECT);
98
+ }
99
+ if (existing === null || typeof existing !== 'object') {
100
+ target[key] = value;
101
+ continue;
102
+ }
103
+ applyJsonMergePatch(existing, value);
104
+ continue;
105
+ }
106
+ target[key] = value;
107
+ }
108
+ }
109
+ export function applyJsonPatch(target, ops) {
110
+ const ok = applyPatch(target, ops);
111
+ if (!ok) {
112
+ throw new Error('[applyJsonPatch] failed to apply one or more operations');
113
+ }
114
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * scenario の `serverActionLogicPath` (TS ファイル) を esbuild で bundle し、
3
+ * Node 側で dynamic import して `GameLogic` を返す。
4
+ *
5
+ * `@uzuhq/code-sdk` は browser 依存 (window global 参照) を top-level に持つため
6
+ * Node runtime で直接 import すると `ReferenceError: window is not defined` で落ちる。
7
+ * esbuild の virtual module plugin で SDK を server-safe な stub に置換して bundle に
8
+ * inline する (`serverOnly` / `isServerOnlyAction` / `SERVER_TIME` だけを提供)。
9
+ */
10
+ import { build } from 'esbuild';
11
+ import { mkdtempSync, rmSync } from 'fs';
12
+ import { tmpdir } from 'os';
13
+ import { join, resolve } from 'path';
14
+ import { pathToFileURL } from 'url';
15
+ const uzuSdkServerStub = {
16
+ name: 'uzu-sdk-server-stub',
17
+ setup(build) {
18
+ // @uzupj/uzu-sdk は旧パッケージ名。移行が済んでいない scenario でも動くよう両対応する
19
+ build.onResolve({ filter: /^(@uzuhq\/code-sdk|@uzupj\/uzu-sdk)$/ }, (args) => ({
20
+ path: args.path,
21
+ namespace: 'uzu-sdk-stub',
22
+ }));
23
+ build.onLoad({ filter: /.*/, namespace: 'uzu-sdk-stub' }, () => ({
24
+ contents: `
25
+ export function serverOnly(fn) {
26
+ fn.__serverOnly = true;
27
+ return fn;
28
+ }
29
+ export function isServerOnlyAction(handler) {
30
+ return handler && handler.__serverOnly === true;
31
+ }
32
+ export const SERVER_TIME = '__SERVER_TIME__';
33
+ export const DEFAULT_ICON_URLS = [];
34
+ `,
35
+ loader: 'js',
36
+ }));
37
+ },
38
+ };
39
+ export async function loadLogicFromPath(logicPath) {
40
+ const absPath = resolve(logicPath);
41
+ const tempDir = mkdtempSync(join(tmpdir(), 'uzu-dev-logic-'));
42
+ const outPath = join(tempDir, 'logic.mjs');
43
+ await build({
44
+ entryPoints: [absPath],
45
+ outfile: outPath,
46
+ bundle: true,
47
+ format: 'esm',
48
+ target: 'es2022',
49
+ platform: 'neutral',
50
+ plugins: [uzuSdkServerStub],
51
+ });
52
+ const mod = (await import(pathToFileURL(outPath).href));
53
+ const logic = mod.default ?? mod.logic;
54
+ if (!logic) {
55
+ throw new Error(`${logicPath} must export a GameLogic object.\n` +
56
+ ` Use either: export default logic\n` +
57
+ ` Or: export const logic: GameLogic<State> = { ... }`);
58
+ }
59
+ return {
60
+ logic,
61
+ dispose: () => {
62
+ try {
63
+ rmSync(tempDir, { recursive: true, force: true });
64
+ }
65
+ catch {
66
+ /* ignore */
67
+ }
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,35 @@
1
+ export class SeededRandomImpl {
2
+ _state;
3
+ constructor(seed) {
4
+ this._state = seed | 0;
5
+ }
6
+ get state() {
7
+ return this._state;
8
+ }
9
+ static fromState(state) {
10
+ const r = new SeededRandomImpl(0);
11
+ r._state = state;
12
+ return r;
13
+ }
14
+ float() {
15
+ this._state |= 0;
16
+ this._state = (this._state + 0x6d2b79f5) | 0;
17
+ let t = Math.imul(this._state ^ (this._state >>> 15), 1 | this._state);
18
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
19
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
20
+ }
21
+ int(max) {
22
+ return Math.floor(this.float() * max);
23
+ }
24
+ pick(array) {
25
+ return array[this.int(array.length)];
26
+ }
27
+ shuffle(array) {
28
+ const a = [...array];
29
+ for (let i = a.length - 1; i > 0; i--) {
30
+ const j = this.int(i + 1);
31
+ [a[i], a[j]] = [a[j], a[i]];
32
+ }
33
+ return a;
34
+ }
35
+ }