@uzuhq/code-cli 0.6.3 → 0.6.4

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
@@ -3,10 +3,10 @@
3
3
  // src/cli.ts
4
4
  import { Command, Option } from "commander";
5
5
  import { execSync } from "child_process";
6
- import { readFileSync as readFileSync6, existsSync as existsSync4, unlinkSync, createWriteStream } from "fs";
7
- import { dirname as dirname5, resolve as resolve6 } from "path";
6
+ import { readFileSync as readFileSync7, existsSync as existsSync5, unlinkSync as unlinkSync2, createWriteStream } from "fs";
7
+ import { dirname as dirname5, resolve as resolve7 } from "path";
8
8
  import { ZipArchive } from "archiver";
9
- import { fileURLToPath as fileURLToPath4, pathToFileURL as pathToFileURL2 } from "url";
9
+ import { fileURLToPath as fileURLToPath4 } from "url";
10
10
 
11
11
  // src/r2-upload.ts
12
12
  import { readFileSync } from "fs";
@@ -418,6 +418,9 @@ var registerRevision = async (params) => {
418
418
 
419
419
  // src/build-server-logic.ts
420
420
  import { build } from "esbuild";
421
+ import { existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync } from "fs";
422
+ import { resolve as resolve2 } from "path";
423
+ import { pathToFileURL } from "url";
421
424
 
422
425
  // src/sdk-server-stub.ts
423
426
  import { existsSync } from "fs";
@@ -461,10 +464,27 @@ var buildServerLogic = async (logicPath, outPath) => {
461
464
  });
462
465
  console.log(`Built server logic: ${logicPath} \u2192 ${outPath}`);
463
466
  };
467
+ var buildAndVerifyServerLogic = async (cwd, serverActionLogicPath) => {
468
+ const logicOutPath = resolve2(cwd, "__logic__.js");
469
+ try {
470
+ await buildServerLogic(resolve2(cwd, serverActionLogicPath), logicOutPath);
471
+ const logicModule = await import(pathToFileURL(logicOutPath).href);
472
+ if (!(logicModule.default ?? logicModule.logic)) {
473
+ throw new Error(
474
+ `${serverActionLogicPath} must export a GameLogic object.
475
+ Use either: export default logic
476
+ Or: export const logic: GameLogic<State> = { ... }`
477
+ );
478
+ }
479
+ return readFileSync2(logicOutPath, "utf-8");
480
+ } finally {
481
+ if (existsSync2(logicOutPath)) unlinkSync(logicOutPath);
482
+ }
483
+ };
464
484
 
465
485
  // src/create-2d-game.ts
466
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync, readdirSync, statSync } from "fs";
467
- import { resolve as resolve2, dirname as dirname2, join as join3 } from "path";
486
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync, readdirSync, statSync } from "fs";
487
+ import { resolve as resolve3, dirname as dirname2, join as join3 } from "path";
468
488
  import { fileURLToPath as fileURLToPath2 } from "url";
469
489
  var __dirname = dirname2(fileURLToPath2(import.meta.url));
470
490
  var copyDir = (src, dest, replacements) => {
@@ -477,13 +497,13 @@ var copyDir = (src, dest, replacements) => {
477
497
  continue;
478
498
  }
479
499
  if (entry.endsWith(".tpl")) {
480
- let content = readFileSync2(srcPath, "utf-8");
500
+ let content = readFileSync3(srcPath, "utf-8");
481
501
  for (const [key, value] of Object.entries(replacements)) {
482
502
  content = content.replaceAll(`{{${key}}}`, value);
483
503
  }
484
504
  writeFileSync(join3(dest, entry.replace(/\.tpl$/, "")), content);
485
505
  } else {
486
- const content = readFileSync2(srcPath, "utf-8");
506
+ const content = readFileSync3(srcPath, "utf-8");
487
507
  let output = content;
488
508
  for (const [key, value] of Object.entries(replacements)) {
489
509
  output = output.replaceAll(`{{${key}}}`, value);
@@ -494,8 +514,8 @@ var copyDir = (src, dest, replacements) => {
494
514
  };
495
515
  var toTitle = (name) => name.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
496
516
  var create2dGame = (name) => {
497
- const dest = resolve2(process.cwd(), name);
498
- const templateDir = resolve2(__dirname, "..", "game-2d-template");
517
+ const dest = resolve3(process.cwd(), name);
518
+ const templateDir = resolve3(__dirname, "..", "game-2d-template");
499
519
  const title = toTitle(name);
500
520
  const replacements = { name, title };
501
521
  console.log(`Creating 2D game project: ${name}`);
@@ -510,7 +530,7 @@ Next steps:`);
510
530
  };
511
531
 
512
532
  // src/cf-images-upload.ts
513
- import { readFileSync as readFileSync3 } from "fs";
533
+ import { readFileSync as readFileSync4 } from "fs";
514
534
  import { basename as basename2 } from "path";
515
535
  var parseUploadedImageId = (v) => {
516
536
  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 !== "") {
@@ -529,7 +549,7 @@ var uploadIconsToCfImages = async (env, token, filePaths) => {
529
549
  filePaths.map(async (filePath, i) => {
530
550
  const image = session.images[i];
531
551
  const formData = new FormData();
532
- formData.append("file", new Blob([readFileSync3(filePath)]), basename2(filePath));
552
+ formData.append("file", new Blob([readFileSync4(filePath)]), basename2(filePath));
533
553
  const res = await fetch(image.uploadURL, { method: "POST", body: formData });
534
554
  if (!res.ok) {
535
555
  const detail = (await res.text()).trim();
@@ -546,8 +566,8 @@ var uploadIconsToCfImages = async (env, token, filePaths) => {
546
566
 
547
567
  // src/dev.ts
548
568
  import { spawn } from "child_process";
549
- import { readFileSync as readFileSync4, existsSync as existsSync2 } from "fs";
550
- import { dirname as dirname3, resolve as resolve4 } from "path";
569
+ import { readFileSync as readFileSync5, existsSync as existsSync3 } from "fs";
570
+ import { dirname as dirname3, resolve as resolve5 } from "path";
551
571
  import { fileURLToPath as fileURLToPath3 } from "url";
552
572
  import { createInterface } from "readline";
553
573
 
@@ -786,7 +806,7 @@ function parseRoster(rosterParam) {
786
806
  return null;
787
807
  }
788
808
  if (!Array.isArray(raw)) return null;
789
- return raw.filter((p) => (p.kind ?? "player") === "player").map((p) => ({
809
+ return raw.map((p) => ({
790
810
  id: p.id,
791
811
  nickname: p.name ?? "Guest",
792
812
  iconUrl: p.iconUrl ?? "",
@@ -798,154 +818,215 @@ function parseRoster(rosterParam) {
798
818
  var BRIDGE_VERSION = 1;
799
819
  var WIRE_VERSION = 2;
800
820
 
801
- // src/dev-server/admin-state-patch.ts
802
- var MERGE_PATCH_ARRAY_REJECT = "[applyJsonMergePatch] cannot merge a non-array patch into an array target";
803
- function applyJsonMergePatch(target, patch) {
804
- if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return;
805
- for (const [key, value] of Object.entries(patch)) {
806
- if (value === void 0) continue;
807
- if (value === null) {
808
- target[key] = null;
809
- continue;
810
- }
811
- if (Array.isArray(value)) {
812
- target[key] = value;
813
- continue;
814
- }
815
- if (typeof value === "object") {
816
- const existing = target[key];
817
- if (Array.isArray(existing)) {
818
- throw new Error(MERGE_PATCH_ARRAY_REJECT);
819
- }
820
- if (existing === null || typeof existing !== "object") {
821
- target[key] = value;
822
- continue;
823
- }
824
- applyJsonMergePatch(existing, value);
825
- continue;
826
- }
827
- target[key] = value;
828
- }
829
- }
830
- function applyJsonPatch(target, ops) {
831
- const ok = applyPatch(target, ops);
832
- if (!ok) {
833
- throw new Error("[applyJsonPatch] failed to apply one or more operations");
834
- }
835
- }
836
-
837
- // src/dev-server/game-room.ts
838
- var GameRoom = class _GameRoom {
821
+ // ../room-core/src/room-core.ts
822
+ var newSeed = () => crypto.getRandomValues(new Uint32Array(1))[0];
823
+ var asString = (value) => typeof value === "string" ? value : null;
824
+ var asNumber = (value) => typeof value === "number" ? value : null;
825
+ var GameRoomCore = class _GameRoomCore {
839
826
  logic;
827
+ label;
828
+ storage;
829
+ /** transport の tick ループ制御用。0 なら tick を回さない。 */
840
830
  tickRate;
831
+ /** 進行中の state。null ならまだ `setup()` が走っていない。 */
841
832
  gameState = null;
842
833
  stateInitialized = false;
843
834
  random = null;
835
+ /** dev harness が `reset({ seed })` の再現に使うので protected。 */
844
836
  seed = 0;
845
837
  tickCount = 0;
846
- tickTimer = null;
847
- tickPaused = false;
848
838
  playerInputs = {};
849
- /**
850
- * roster。 配役を受け取る player 席のみで、 観測席 (GM 席・観戦席) は載らない。
851
- * dev harness では manifest から組んだ roster を constructor で注入する
852
- * (server 権威)。 その場合、 接続クエリの roster 申告は一切採用しないので、
853
- * 旧世代 harness page の残タブが reconnect しても roster を汚染できない。
854
- * 本番 DO は backend 由来の roster を全 client が同一申告するため接続時
855
- * 登録で成立している — 権威が platform 側にある点は同じ。
856
- */
839
+ /** 所与の roster。配役を受け取る player 席のみで、観測席 (GM 席・観戦席) は載らない。 */
857
840
  players = [];
841
+ /** 差分同期用シーケンス番号 */
858
842
  seq = 0;
843
+ /** 前回ブロードキャスト時の state スナップショット(差分計算ベース) */
859
844
  prevBroadcastState = null;
845
+ /** N回に1回フルスナップショットを送信する間隔 */
860
846
  static SNAPSHOT_INTERVAL = 20;
847
+ /** storage 書き込みデバウンス用タイマー */
848
+ storageFlushTimer = null;
849
+ static STORAGE_DEBOUNCE_MS = 500;
850
+ /** 処理中の RPC 1 回分の送信バッファ。各 RPC の入口で begin() が張り替える。 */
851
+ out = { toSender: [], broadcast: [] };
861
852
  // ─── ゲーム内時計 ──────────────────────────────────────
862
- // 算術と凍結の出入りは engine-core の GameClock (facet と共有)。
853
+ // 算術と凍結の出入りは engine-core の GameClock に閉じている (ソロ実装とも共有)。
854
+ // ここが持つのは永続化と、停止に付随する情報だけ。
863
855
  clock = new GameClock();
856
+ /** 緊急停止を要求した playerId (表示用)。 */
864
857
  pausedBy = null;
858
+ /** 接続が 0 になった実時刻。猶予の判定にだけ使う。 */
865
859
  lastEmptyAtWall = null;
866
860
  /** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
867
861
  warnedDeadlines = /* @__PURE__ */ new Set();
868
- sockets = /* @__PURE__ */ new Set();
869
- attachments = /* @__PURE__ */ new WeakMap();
870
- snapshotSubscribers = /* @__PURE__ */ new Set();
871
- eventSubscribers = /* @__PURE__ */ new Set();
872
- constructor(logic, players) {
862
+ constructor(logic, opts) {
873
863
  this.logic = logic;
864
+ this.label = opts.label;
865
+ this.storage = opts.storage ?? null;
874
866
  this.tickRate = logic.tickRate ?? 0;
875
- if (players && players.length > 0) {
876
- this.players = players;
867
+ if (opts.players && opts.players.length > 0) {
868
+ this.players = opts.players;
877
869
  console.log(
878
- `[GameRoom] \u{1F4CB} Roster (server-authoritative): ${players.map((p) => p.id).join(", ")}`
870
+ `${this.label} \u{1F4CB} Roster (server-authoritative): ${opts.players.map((p) => p.id).join(", ")}`
879
871
  );
880
872
  }
881
873
  }
882
- // ─── ゲーム内時計 ───────────────────────────────────────
883
- /** ゲーム開始からの ms。停止中は進まない。Unix epoch ではない。 */
884
874
  gameTime() {
885
875
  return this.clock.now();
886
876
  }
887
877
  get frozen() {
888
878
  return this.clock.frozen;
889
879
  }
880
+ /** 凍結の出入りは必ずここを通す。時計と storage が一緒に動くのを保証する。 */
881
+ freeze(reason) {
882
+ if (this.clock.freeze(reason)) this.persistClock();
883
+ }
884
+ /** 実際に理由を外したら true。呼び出し側が二重 put を避けるために使う。 */
885
+ unfreeze(reason) {
886
+ if (!this.clock.unfreeze(reason)) return false;
887
+ this.persistClock();
888
+ return true;
889
+ }
890
890
  /** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
891
891
  timeCtx(time) {
892
892
  return { time, after: (d) => plus(time, d) };
893
893
  }
894
- freeze(reason) {
895
- if (!this.clock.freeze(reason)) return;
896
- this.stopTickLoop();
897
- this.syncWakeup();
894
+ // ─── Outcome ───────────────────────────────────────────
895
+ begin() {
896
+ this.out = { toSender: [], broadcast: [] };
898
897
  }
899
- unfreeze(reason) {
900
- if (!this.clock.unfreeze(reason)) return;
901
- this.syncWakeup();
902
- this.ensureTickLoop();
898
+ finish() {
899
+ return {
900
+ toSender: this.out.toSender,
901
+ broadcast: this.out.broadcast,
902
+ nextDeadline: this.nextDeadline(),
903
+ tickRate: this.tickRate,
904
+ started: this.gameState !== null,
905
+ frozen: this.frozen
906
+ };
903
907
  }
908
+ // ─── Hibernation Recovery ──────────────────────────────
904
909
  /**
905
- * 停止の状態を全接続へ配る。
910
+ * 永続化したインメモリ state を復元する。
906
911
  *
907
- * `no-players` は見せない。外れる条件が「接続した」なので、クライアントが見られる
908
- * 状態では必ず外れている。
912
+ * facet はコード更新 (`facets.abort()`) や eviction で再起動されるので、**全 RPC の
913
+ * 入口で呼ぶこと**。呼び忘れると再起動後の `__action` が `state === null` で無視され、
914
+ * プレイヤーの入力がサーバーに反映されない。
915
+ *
916
+ * 揮発 (storage なし) なら何も読むものが無いので即座に返る。
909
917
  */
910
- broadcastPauseState() {
911
- this.broadcastAll({
912
- type: "__pause_state",
913
- frozen: this.clock.isFrozenBy("emergency-stop"),
914
- by: this.pausedBy,
915
- gameTime: this.gameTime()
918
+ async hydrate() {
919
+ if (this.stateInitialized || this.storage === null) return;
920
+ const savedState = asString(await this.storage.get("state"));
921
+ if (savedState === null) return;
922
+ this.gameState = JSON.parse(savedState);
923
+ this.stateInitialized = true;
924
+ this.tickCount = asNumber(await this.storage.get("tickCount")) ?? 0;
925
+ this.seed = asNumber(await this.storage.get("seed")) ?? newSeed();
926
+ const savedRandomState = asNumber(await this.storage.get("randomState"));
927
+ this.random = savedRandomState !== null ? SeededRandomImpl.fromState(savedRandomState) : new SeededRandomImpl(this.seed);
928
+ const savedRoster = asString(await this.storage.get("seats"));
929
+ if (savedRoster) this.players = JSON.parse(savedRoster);
930
+ this.seq = asNumber(await this.storage.get("seq")) ?? 0;
931
+ await this.loadClock();
932
+ this.prevBroadcastState = null;
933
+ console.log(
934
+ `${this.label} \u{1F504} State recovered from storage tick=${this.tickCount} seq=${this.seq} frozenBy=[${this.clock.snapshot().frozenBy.join(",")}]`
935
+ );
936
+ }
937
+ // ─── Storage Persistence ───────────────────────────────
938
+ /** 揮発なら書かない。dev-server は CLI restart で state を失う前提で動いている。 */
939
+ persist(entries) {
940
+ const storage = this.storage;
941
+ if (storage === null) return;
942
+ Promise.all(Object.entries(entries).map(([key, value]) => storage.put(key, value))).catch(
943
+ (err) => console.error(`${this.label} \u274C Storage put failed`, err)
944
+ );
945
+ }
946
+ /**
947
+ * 時計を即時 put する。**停止の遷移だけは debounce を通さない。**
948
+ *
949
+ * 停止直後に evict されて停止が消えると、世界が勝手に動き出す。
950
+ */
951
+ persistClock() {
952
+ this.persist({
953
+ clock: JSON.stringify(this.clock.snapshot()),
954
+ pausedBy: this.pausedBy,
955
+ lastEmptyAtWall: this.lastEmptyAtWall
916
956
  });
917
957
  }
918
- // ─── Broadcast ─────────────────────────────────────────
919
- broadcastAll(msg) {
920
- const data = JSON.stringify(msg);
921
- for (const ws of this.sockets) {
958
+ /** 欠損は「未停止」に倒す。読めなかったせいで世界が止まったままになる方が悪い。 */
959
+ async loadClock() {
960
+ const storage = this.storage;
961
+ if (storage === null) return;
962
+ const saved = asString(await storage.get("clock"));
963
+ if (saved !== null) {
922
964
  try {
923
- ws.send(data);
924
- } catch {
965
+ this.clock.restore(JSON.parse(saved));
966
+ } catch (err) {
967
+ console.error(`${this.label} \u274C Clock restore failed \u2192 \u672A\u505C\u6B62\u3068\u3057\u3066\u7D9A\u884C`, err);
925
968
  }
926
969
  }
970
+ this.pausedBy = asString(await storage.get("pausedBy"));
971
+ this.lastEmptyAtWall = asNumber(await storage.get("lastEmptyAtWall"));
972
+ if (!this.clock.isFrozenBy("emergency-stop")) this.pausedBy = null;
927
973
  }
928
- sendTo(ws, msg) {
929
- try {
930
- ws.send(JSON.stringify(msg));
931
- } catch {
974
+ cancelStorageFlush() {
975
+ if (this.storageFlushTimer !== null) {
976
+ clearTimeout(this.storageFlushTimer);
977
+ this.storageFlushTimer = null;
932
978
  }
933
979
  }
980
+ /**
981
+ * デバウンス付き storage 書き込み。ブロードキャストのクリティカルパスから I/O を除去。
982
+ */
983
+ debouncedPersist() {
984
+ if (this.storage === null) return;
985
+ this.cancelStorageFlush();
986
+ this.storageFlushTimer = setTimeout(() => {
987
+ this.storageFlushTimer = null;
988
+ this.persist({
989
+ state: JSON.stringify(this.gameState),
990
+ tickCount: this.tickCount,
991
+ randomState: this.random?.state ?? 0,
992
+ seq: this.seq
993
+ });
994
+ }, _GameRoomCore.STORAGE_DEBOUNCE_MS);
995
+ }
996
+ // ─── Message Buffers ───────────────────────────────────
997
+ broadcastAll(msg) {
998
+ this.out.broadcast.push(JSON.stringify(msg));
999
+ }
1000
+ sendToSender(msg) {
1001
+ this.out.toSender.push(JSON.stringify(msg));
1002
+ }
1003
+ /**
1004
+ * state を配ったあとに呼ばれる。dev harness の購読 (`subscribeSnapshot` /
1005
+ * `subscribeEvents`) はここに乗る。本番は override しないので何もしない。
1006
+ */
1007
+ afterStateChanged(_events) {
1008
+ }
1009
+ /**
1010
+ * 差分 or フルで state をブロードキャストする。
1011
+ * prevBroadcastState が null、または SNAPSHOT_INTERVAL ごとにフル送信。
1012
+ * それ以外は JSON Patch 差分を送信し、サイズが小さい方を採用する。
1013
+ */
934
1014
  broadcastStateDelta(events, extra) {
935
1015
  this.seq++;
936
1016
  const gameTime = this.gameTime();
937
1017
  const fullType = extra.ack !== void 0 ? "__action_result" : "__tick";
938
1018
  const deltaType = extra.ack !== void 0 ? "__action_result_delta" : "__tick_delta";
939
- const needFull = this.prevBroadcastState === null || this.seq % _GameRoom.SNAPSHOT_INTERVAL === 0;
1019
+ const needFull = this.prevBroadcastState === null || this.seq % _GameRoomCore.SNAPSHOT_INTERVAL === 0;
1020
+ const fullPayload = JSON.stringify({
1021
+ type: fullType,
1022
+ state: this.gameState,
1023
+ events,
1024
+ seq: this.seq,
1025
+ gameTime,
1026
+ ...extra
1027
+ });
940
1028
  if (needFull) {
941
- this.broadcastAll({
942
- type: fullType,
943
- state: this.gameState,
944
- events,
945
- seq: this.seq,
946
- gameTime,
947
- ...extra
948
- });
1029
+ this.out.broadcast.push(fullPayload);
949
1030
  } else {
950
1031
  const patches = compare(this.prevBroadcastState, this.gameState);
951
1032
  const deltaPayload = JSON.stringify({
@@ -956,75 +1037,169 @@ var GameRoom = class _GameRoom {
956
1037
  gameTime,
957
1038
  ...extra
958
1039
  });
959
- const fullPayload = JSON.stringify({
960
- type: fullType,
961
- state: this.gameState,
962
- events,
963
- seq: this.seq,
964
- gameTime,
965
- ...extra
966
- });
967
- const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
968
- for (const ws of this.sockets) {
969
- try {
970
- ws.send(data);
971
- } catch {
972
- }
973
- }
1040
+ this.out.broadcast.push(
1041
+ deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload
1042
+ );
974
1043
  }
975
1044
  this.prevBroadcastState = structuredClone(this.gameState);
976
- this.notifySnapshotSubscribers();
977
- if (events.length > 0) this.notifyEventSubscribers(events);
1045
+ this.afterStateChanged(events);
978
1046
  }
979
- notifySnapshotSubscribers() {
980
- this.snapshotSubscribers.forEach((cb) => {
981
- try {
982
- cb(this.gameState);
983
- } catch (err) {
984
- console.warn("[GameRoom] snapshot subscriber threw:", err);
985
- }
986
- });
1047
+ // ─── Deadlines ─────────────────────────────────────────
1048
+ hasAction(name) {
1049
+ return Boolean(this.logic.actions[name] || this.logic.serverActions?.[name]);
987
1050
  }
988
- notifyEventSubscribers(events) {
989
- const frozen = Object.freeze(events.slice());
990
- this.eventSubscribers.forEach((cb) => {
1051
+ /**
1052
+ * 宣言された締切のうち最も早いゲーム内時刻。締切が無ければ null。
1053
+ *
1054
+ * state から毎回導出するので保存しない。action が throw して state が巻き戻れば
1055
+ * 締切も一緒に巻き戻る。
1056
+ */
1057
+ earliestDeadline() {
1058
+ if (!this.gameState || !this.logic.deadlines) return null;
1059
+ let earliest = null;
1060
+ for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
1061
+ let raw;
991
1062
  try {
992
- cb(frozen);
1063
+ raw = deadline.at({ state: this.gameState });
993
1064
  } catch (err) {
994
- console.warn("[GameRoom] event subscriber threw:", err);
1065
+ console.error(`[Deadline] \u274C ${key}.at() \u3067\u4F8B\u5916`, err);
1066
+ continue;
995
1067
  }
1068
+ const at = resolveDeadline(
1069
+ raw,
1070
+ key,
1071
+ this.warnedDeadlines,
1072
+ (m) => console.error(`[Deadline] \u274C ${m}`)
1073
+ );
1074
+ if (at === null) continue;
1075
+ if (earliest === null || at < earliest) earliest = at;
1076
+ }
1077
+ return earliest;
1078
+ }
1079
+ /**
1080
+ * transport に写す次の起床**実時刻**。
1081
+ *
1082
+ * 停止中はシナリオの締切を予約しない。停止に入った時点で予約済みの起床は残って
1083
+ * 発火するが、期限はゲーム内時刻で見るので空振りし、再アームで自己修復する。
1084
+ */
1085
+ nextDeadline() {
1086
+ const candidates = [];
1087
+ if (!this.frozen) {
1088
+ const earliest = this.earliestDeadline();
1089
+ if (earliest !== null) candidates.push(this.clock.toWall(earliest));
1090
+ }
1091
+ if (this.lastEmptyAtWall !== null && !this.clock.isFrozenBy("no-players")) {
1092
+ candidates.push(this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS);
1093
+ }
1094
+ return candidates.length === 0 ? null : Math.min(...candidates);
1095
+ }
1096
+ /** 猶予が満了していれば無人凍結に入る。 */
1097
+ freezeIfEmptyGraceExpired() {
1098
+ if (this.lastEmptyAtWall === null) return;
1099
+ if (this.clock.isFrozenBy("no-players")) return;
1100
+ if (Date.now() < this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS) return;
1101
+ console.log(
1102
+ `${this.label} \u{1F9CA} \u7121\u4EBA\u7336\u4E88 ${NO_PLAYERS_GRACE_MS}ms \u6E80\u4E86 \u2192 \u6642\u8A08\u3092\u505C\u6B62 gameTime=${this.gameTime()}`
1103
+ );
1104
+ this.freeze("no-players");
1105
+ }
1106
+ /**
1107
+ * 停止の状態を全接続へ配る。
1108
+ *
1109
+ * `no-players` は見せない。外れる条件が「接続した」なので、クライアントが見られる
1110
+ * 状態では必ず外れている。overlay は `emergency-stop` だけで出す。
1111
+ */
1112
+ broadcastPauseState() {
1113
+ this.broadcastAll({
1114
+ type: "__pause_state",
1115
+ frozen: this.clock.isFrozenBy("emergency-stop"),
1116
+ by: this.pausedBy,
1117
+ gameTime: this.gameTime()
996
1118
  });
997
1119
  }
998
- // ─── Game Lifecycle ────────────────────────────────────
999
1120
  /**
1000
- * 移行期: 手元のシナリオが `setup({ seats })` のままでも `uzu dev` を動かせるよう、
1001
- * 同じ配列を旧名でも渡す。 本番 (play-server) が publish 済み logic.js のために
1002
- * 同じことをしているのと揃えている。 `SetupArgs` `seats` を宣言しないのは、
1003
- * 新規シナリオに旧名を選ばせないため。
1121
+ * action 1 件実行して broadcast する。
1122
+ *
1123
+ * WebSocket 経由 (ack あり) と予約発火 (ack 無し) の両方から使う。
1124
+ * 「全部成功か全部失敗か」にするため、throw したら state を巻き戻して broadcast しない。
1004
1125
  */
1005
- setupArgs(random) {
1126
+ async runAction(actionName, payload, senderId, opts) {
1127
+ if (!this.gameState) return { ok: false, error: "Game not started" };
1128
+ const plain = this.logic.actions[actionName];
1129
+ const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
1130
+ const serverHandler = this.logic.serverActions?.[actionName] ?? legacyServerOnly;
1131
+ if (!plain && !serverHandler) return { ok: false, error: `Unknown action: ${actionName}` };
1132
+ const events = [];
1133
+ const emit = (name, data) => events.push({ name, data: data ?? {} });
1134
+ const serverEmit = emit;
1135
+ const snapshot = structuredClone(this.gameState);
1136
+ const time = this.gameTime();
1137
+ try {
1138
+ if (plain && !legacyServerOnly) {
1139
+ const state = this.gameState;
1140
+ withGameClock(
1141
+ time,
1142
+ () => plain({
1143
+ state,
1144
+ payload,
1145
+ playerId: senderId,
1146
+ ctx: { ...this.timeCtx(time), emit }
1147
+ })
1148
+ );
1149
+ }
1150
+ if (serverHandler) {
1151
+ await serverHandler({
1152
+ state: this.gameState,
1153
+ payload,
1154
+ playerId: senderId,
1155
+ ctx: {
1156
+ tick: this.tickCount,
1157
+ random: this.random ?? new SeededRandomImpl(this.seed),
1158
+ ...this.timeCtx(time),
1159
+ emit: serverEmit
1160
+ }
1161
+ });
1162
+ }
1163
+ } catch (err) {
1164
+ this.gameState = snapshot;
1165
+ return { ok: false, error: err instanceof Error ? err.message : "unknown", cause: err };
1166
+ }
1167
+ this.broadcastStateDelta(
1168
+ events,
1169
+ opts.ack !== void 0 ? { ack: opts.ack, from: senderId } : {}
1170
+ );
1171
+ this.debouncedPersist();
1172
+ return { ok: true };
1173
+ }
1174
+ // ─── Game Lifecycle ────────────────────────────────────
1175
+ setupArgs(random, setupTime) {
1006
1176
  return {
1007
1177
  players: this.players,
1008
- seats: this.players,
1009
- ctx: { random, ...this.timeCtx(this.gameTime()) }
1178
+ ctx: { random, ...this.timeCtx(setupTime) }
1010
1179
  };
1011
1180
  }
1012
- maybeStartGame() {
1013
- if (this.gameState !== null) return;
1014
- if (this.players.length === 0) return;
1015
- this.seed = Math.floor(Math.random() * 4294967295);
1016
- this.random = new SeededRandomImpl(this.seed);
1181
+ /**
1182
+ * `setup()` を走らせて部屋を起動する。開始と reset の共通部。
1183
+ *
1184
+ * ctx は withGameClock の外で組み立てる。中で gameTime() を読むと、差し替えた
1185
+ * Date.now を実時刻として引き算してしまう。
1186
+ */
1187
+ startGame(seed) {
1188
+ this.seed = seed;
1189
+ this.random = new SeededRandomImpl(seed);
1017
1190
  this.clock.restart();
1018
1191
  this.pausedBy = null;
1019
1192
  this.lastEmptyAtWall = null;
1020
1193
  const setupTime = this.gameTime();
1021
- const args = this.setupArgs(this.random);
1194
+ const args = this.setupArgs(this.random, setupTime);
1022
1195
  this.gameState = withGameClock(setupTime, () => this.logic.setup(args));
1023
1196
  this.stateInitialized = true;
1024
1197
  this.tickCount = 0;
1025
1198
  this.seq = 0;
1026
1199
  this.prevBroadcastState = structuredClone(this.gameState);
1027
- console.log(`[GameRoom] \u2705 Game started with ${this.players.length} players`);
1200
+ this.persist({ seats: JSON.stringify(this.players), seed: this.seed });
1201
+ this.persistClock();
1202
+ this.debouncedPersist();
1028
1203
  this.broadcastAll({
1029
1204
  type: "__game_start",
1030
1205
  state: this.gameState,
@@ -1032,62 +1207,46 @@ var GameRoom = class _GameRoom {
1032
1207
  seq: 0,
1033
1208
  gameTime: this.gameTime()
1034
1209
  });
1035
- if (this.tickRate > 0) this.startTickLoop();
1036
- this.syncWakeup();
1037
- this.notifySnapshotSubscribers();
1210
+ this.afterStateChanged([]);
1038
1211
  }
1039
- startTickLoop() {
1040
- if (this.tickTimer) clearInterval(this.tickTimer);
1041
- this.tickTimer = setInterval(() => this.tick(), 1e3 / this.tickRate);
1042
- }
1043
- // 全クライアント切断で tickTimer は止まる (handleClose) が gameState は残るため、
1044
- // 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
1045
- ensureTickLoop() {
1046
- if (this.tickRate > 0 && this.gameState !== null && this.sockets.size > 0 && !this.tickPaused && !this.frozen && !this.tickTimer) {
1047
- this.startTickLoop();
1048
- }
1212
+ /**
1213
+ * roster が確定していればゲームを開始する。
1214
+ * roster は所与 (注入か URL players パラメータ)。接続の有無には依存しない。
1215
+ */
1216
+ maybeStartGame() {
1217
+ if (this.gameState !== null) return;
1218
+ if (this.players.length === 0) return;
1219
+ this.startGame(newSeed());
1220
+ console.log(`${this.label} \u2705 Game started with ${this.players.length} players`);
1049
1221
  }
1050
- stopTickLoop() {
1051
- if (this.tickTimer) {
1052
- clearInterval(this.tickTimer);
1053
- this.tickTimer = null;
1054
- }
1222
+ /**
1223
+ * 部屋を作り直す。dev harness の `reset()` 専用で、本番の RPC からは到達しない。
1224
+ *
1225
+ * 新規ゲームなので時計も 0 から始め直す。
1226
+ */
1227
+ resetGame(opts) {
1228
+ this.begin();
1229
+ const seed = opts?.seed === "random" ? newSeed() : typeof opts?.seed === "number" ? opts.seed : this.seed;
1230
+ this.startGame(seed);
1231
+ return this.finish();
1055
1232
  }
1056
- tick() {
1057
- if (this.tickPaused || this.frozen) return;
1058
- this.runOneTick();
1233
+ // ─── Roster ────────────────────────────────────────────
1234
+ /**
1235
+ * 申告 roster が pin 済み roster と食い違ったら警告する。挙動は変えない。
1236
+ *
1237
+ * roster は端末ごとに組まれるうえ roomId は固定なので、参加者が確定する前に誰か
1238
+ * 1 人が接続すると、その時点の顔ぶれで部屋が固定される。溢れた player 席は
1239
+ * state.players に居ないまま繋がり、真っ黒ではなく静かに観測者ビューになる。
1240
+ * 例外も出ないため、ログでしか気付けない。
1241
+ */
1242
+ warnIfRosterDiffers(declared, seatId) {
1243
+ const pinned = this.players.map((p) => p.id).sort();
1244
+ const incoming = declared.map((p) => p.id).sort();
1245
+ if (pinned.length === incoming.length && pinned.every((id, i) => id === incoming[i])) return;
1246
+ console.warn(
1247
+ `${this.label} \u26A0\uFE0F Roster mismatch seatId=${seatId} pinned=[${pinned.join(",")}] declared=[${incoming.join(",")}]`
1248
+ );
1059
1249
  }
1060
- runOneTick() {
1061
- if (!this.gameState || !this.random) return;
1062
- const events = [];
1063
- const emit = (name, data) => events.push({ name, data: data ?? {} });
1064
- const time = this.gameTime();
1065
- const random = this.random;
1066
- const state = this.gameState;
1067
- try {
1068
- withGameClock(
1069
- time,
1070
- () => this.logic.update({
1071
- state,
1072
- ctx: {
1073
- random,
1074
- tick: this.tickCount,
1075
- ...this.timeCtx(time),
1076
- emit,
1077
- playerInputs: this.playerInputs
1078
- }
1079
- })
1080
- );
1081
- } catch (err) {
1082
- console.error(`[GameRoom] tick error at tick=${this.tickCount}:`, err);
1083
- this.tickCount++;
1084
- return;
1085
- }
1086
- this.tickCount++;
1087
- this.syncWakeup();
1088
- this.broadcastStateDelta(events, { tick: this.tickCount });
1089
- }
1090
- // ─── Connection ────────────────────────────────────────
1091
1250
  /**
1092
1251
  * 復帰用の state メッセージ。
1093
1252
  *
@@ -1104,251 +1263,173 @@ var GameRoom = class _GameRoom {
1104
1263
  pausedBy: this.pausedBy
1105
1264
  };
1106
1265
  }
1107
- handleConnection(ws, url) {
1108
- const playerId = url.searchParams.get("seatId");
1109
- if (!playerId) {
1110
- ws.close(1008, "Missing required query parameter: seatId");
1111
- return;
1112
- }
1113
- const nickname = url.searchParams.get("nickname") ?? "Guest";
1114
- const connectionId = randomUUID();
1266
+ // ─── RPC (transport から呼ばれる) ───────────────────────
1267
+ /**
1268
+ * 接続 1 件の受け入れ処理。transport が WS を受け入れた後に呼ぶ。
1269
+ * 進行中なら toSender __state、roster 確定でゲーム開始なら broadcast に
1270
+ * __game_start が載る (両者は排他: 開始済みなら maybeStartGame は no-op)。
1271
+ *
1272
+ * IMPORTANT: 永続化ありの transport は**必ず [hydrate] を先に await すること**。
1273
+ * facet 再起動直後は this.players が空なので、復元前にここへ入ると pin 済み roster を
1274
+ * 持つ部屋を未 pin と誤認し、申告どおりに上書きしてしまう。
1275
+ */
1276
+ connect(playerId, rosterParam) {
1277
+ this.begin();
1278
+ const declaredRoster = parseRoster(rosterParam);
1115
1279
  if (this.players.length === 0) {
1116
- const declared = parseRoster(
1117
- url.searchParams.get("players") ?? url.searchParams.get("seats")
1118
- );
1119
- if (declared && declared.length > 0) {
1120
- this.players = declared;
1121
- console.log(`[GameRoom] \u{1F4CB} Roster pinned: ${this.players.map((p) => p.id).join(", ")}`);
1280
+ if (declaredRoster && declaredRoster.length > 0) {
1281
+ this.players = declaredRoster;
1282
+ console.log(`${this.label} \u{1F4CB} Roster pinned: ${this.players.map((p) => p.id).join(", ")}`);
1122
1283
  }
1284
+ } else if (declaredRoster) {
1285
+ this.warnIfRosterDiffers(declaredRoster, playerId);
1123
1286
  }
1124
- console.log(
1125
- `[GameRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`
1126
- );
1127
- this.sockets.add(ws);
1128
- this.attachments.set(ws, { connectionId, playerId, nickname });
1129
- ws.on("message", (raw) => {
1130
- void this.handleMessage(ws, raw.toString());
1131
- });
1132
- ws.on("close", () => this.handleClose(ws));
1133
- ws.on("error", () => this.handleClose(ws));
1134
- this.sendTo(ws, {
1135
- type: "__room_init",
1136
- myId: playerId
1137
- });
1287
+ const hadEmptyMark = this.lastEmptyAtWall !== null;
1138
1288
  this.lastEmptyAtWall = null;
1139
- this.unfreeze("no-players");
1289
+ if (!this.unfreeze("no-players") && hadEmptyMark) this.persistClock();
1140
1290
  if (this.stateInitialized && this.gameState !== null) {
1141
- this.sendTo(ws, this.stateMessage());
1291
+ this.sendToSender(this.stateMessage());
1142
1292
  }
1143
1293
  this.maybeStartGame();
1144
- this.ensureTickLoop();
1294
+ return this.finish();
1145
1295
  }
1146
- async handleMessage(ws, msg) {
1147
- if (msg === "__ping") {
1148
- try {
1149
- ws.send("__pong");
1150
- } catch {
1151
- }
1152
- return;
1153
- }
1154
- const attachment = this.attachments.get(ws);
1155
- if (!attachment) return;
1156
- const senderId = attachment.playerId;
1296
+ /** WS メッセージ 1 件の処理。 */
1297
+ async message(senderId, msg) {
1298
+ this.begin();
1157
1299
  let parsed;
1158
1300
  try {
1159
1301
  parsed = JSON.parse(msg);
1160
1302
  } catch {
1161
- return;
1303
+ return this.finish();
1162
1304
  }
1163
1305
  const msgType = parsed.type;
1164
- console.log(`[GameRoom] \u2B05 recv from=${senderId} type=${msgType}`);
1306
+ console.log(`${this.label} \u2B05 recv from=${senderId} type=${msgType}`);
1165
1307
  if (msgType === "__pause") {
1166
- if (!this.gameState) return;
1308
+ if (!this.gameState) return this.finish();
1167
1309
  if (!this.clock.isFrozenBy("emergency-stop")) {
1168
1310
  this.pausedBy = senderId;
1169
1311
  this.freeze("emergency-stop");
1170
- console.log(`[GameRoom] \u23F8 \u7DCA\u6025\u505C\u6B62 by=${senderId} gameTime=${this.gameTime()}`);
1312
+ console.log(`${this.label} \u23F8 \u7DCA\u6025\u505C\u6B62 by=${senderId} gameTime=${this.gameTime()}`);
1171
1313
  }
1172
1314
  this.broadcastPauseState();
1173
- return;
1315
+ return this.finish();
1174
1316
  }
1175
1317
  if (msgType === "__resume") {
1176
1318
  if (this.clock.isFrozenBy("emergency-stop")) {
1177
- this.unfreeze("emergency-stop");
1178
1319
  this.pausedBy = null;
1179
- console.log(`[GameRoom] \u25B6\uFE0F \u7DCA\u6025\u505C\u6B62\u3092\u89E3\u9664 by=${senderId} gameTime=${this.gameTime()}`);
1320
+ this.unfreeze("emergency-stop");
1321
+ console.log(`${this.label} \u25B6\uFE0F \u7DCA\u6025\u505C\u6B62\u3092\u89E3\u9664 by=${senderId} gameTime=${this.gameTime()}`);
1180
1322
  }
1181
1323
  this.broadcastPauseState();
1182
- return;
1324
+ return this.finish();
1183
1325
  }
1184
1326
  if (this.frozen && (msgType === "__action" || msgType === "__input")) {
1185
1327
  if (msgType === "__action") {
1186
- this.sendTo(ws, { type: "__action_error", error: "Paused", seq: parsed.seq });
1328
+ this.sendToSender({ type: "__action_error", error: "Paused", seq: parsed.seq });
1187
1329
  }
1188
- return;
1330
+ return this.finish();
1189
1331
  }
1190
1332
  if (msgType === "__action") {
1191
1333
  if (!this.gameState) {
1192
- this.sendTo(ws, {
1334
+ this.sendToSender({
1193
1335
  type: "__action_error",
1194
1336
  error: "Game not started",
1195
1337
  seq: parsed.seq
1196
1338
  });
1197
- return;
1339
+ return this.finish();
1198
1340
  }
1199
1341
  const actionName = parsed.action;
1200
1342
  const payload = parsed.payload ?? {};
1201
1343
  const seq = parsed.seq;
1202
- try {
1203
- await this.dispatchAction(actionName, payload, senderId, seq);
1204
- } catch (err) {
1205
- this.sendTo(ws, {
1344
+ if (!this.hasAction(actionName)) {
1345
+ this.sendToSender({
1206
1346
  type: "__action_error",
1207
- error: `Action failed: ${err instanceof Error ? err.message : "unknown"}`,
1347
+ error: `Unknown action: ${actionName}`,
1208
1348
  seq
1209
1349
  });
1350
+ return this.finish();
1210
1351
  }
1211
- return;
1352
+ const outcome = await this.runAction(
1353
+ actionName,
1354
+ payload,
1355
+ senderId,
1356
+ {
1357
+ ack: seq
1358
+ }
1359
+ );
1360
+ if (!outcome.ok) {
1361
+ this.sendToSender({
1362
+ type: "__action_error",
1363
+ error: `Action failed: ${outcome.error}`,
1364
+ seq
1365
+ });
1366
+ }
1367
+ return this.finish();
1212
1368
  }
1213
1369
  if (msgType === "__request_state") {
1214
1370
  if (this.gameState !== null) {
1215
- this.sendTo(ws, this.stateMessage());
1371
+ this.sendToSender(this.stateMessage());
1216
1372
  }
1217
- return;
1373
+ return this.finish();
1218
1374
  }
1219
1375
  if (msgType === "__input") {
1220
1376
  const inputData = parsed.data;
1221
- if (inputData) this.playerInputs[senderId] = inputData;
1222
- return;
1377
+ if (inputData) {
1378
+ this.playerInputs[senderId] = inputData;
1379
+ }
1380
+ return this.finish();
1223
1381
  }
1382
+ return this.finish();
1224
1383
  }
1225
- async dispatchAction(actionName, payload, senderId, ackSeq) {
1226
- const plain = this.logic.actions[actionName];
1227
- const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
1228
- const serverHandler = this.logic.serverActions?.[actionName] ?? legacyServerOnly;
1229
- if (!plain && !serverHandler) {
1230
- throw new Error(`Unknown action: ${actionName}`);
1231
- }
1232
- if (!this.gameState) {
1233
- throw new Error("Game not started");
1234
- }
1384
+ /** tick 1 周分。transport tick ループから呼ばれる。 */
1385
+ tick() {
1386
+ this.begin();
1387
+ const random = this.random;
1388
+ if (!this.gameState || !random) return this.finish();
1389
+ if (this.frozen) return this.finish();
1235
1390
  const events = [];
1236
1391
  const emit = (name, data) => events.push({ name, data: data ?? {} });
1237
- const serverEmit = emit;
1238
- const snapshot = structuredClone(this.gameState);
1239
1392
  const time = this.gameTime();
1393
+ const state = this.gameState;
1240
1394
  try {
1241
- if (plain && !legacyServerOnly) {
1242
- const state = this.gameState;
1243
- withGameClock(
1244
- time,
1245
- () => plain({
1246
- state,
1247
- payload: payload ?? {},
1248
- playerId: senderId,
1249
- ctx: { ...this.timeCtx(time), emit }
1250
- })
1251
- );
1252
- }
1253
- if (serverHandler) {
1254
- await serverHandler({
1255
- state: this.gameState,
1256
- payload: payload ?? {},
1257
- playerId: senderId,
1395
+ withGameClock(
1396
+ time,
1397
+ () => this.logic.update({
1398
+ state,
1258
1399
  ctx: {
1400
+ random,
1259
1401
  tick: this.tickCount,
1260
- random: this.random ?? new SeededRandomImpl(this.seed),
1261
1402
  ...this.timeCtx(time),
1262
- emit: serverEmit
1403
+ emit,
1404
+ playerInputs: this.playerInputs
1263
1405
  }
1264
- });
1265
- }
1266
- } catch (err) {
1267
- this.gameState = snapshot;
1268
- throw err;
1269
- }
1270
- this.syncWakeup();
1271
- this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
1272
- }
1273
- // ─── Deadlines (本番の Durable Object alarm 相当) ──────────
1274
- /** dev は setTimeout。本番は storage の alarm。意味論は同じ。 */
1275
- wakeupTimer = null;
1276
- wakeupAt = null;
1277
- /**
1278
- * 宣言された締切のうち最も早いゲーム内時刻。締切が無ければ null。
1279
- * state から毎回導出するので保存しない。
1280
- */
1281
- earliestDeadline() {
1282
- if (!this.gameState || !this.logic.deadlines) return null;
1283
- let earliest = null;
1284
- for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
1285
- let raw;
1286
- try {
1287
- raw = deadline.at({ state: this.gameState });
1288
- } catch (err) {
1289
- console.error(`[Deadline] \u274C ${key}.at() \u3067\u4F8B\u5916`, err);
1290
- continue;
1291
- }
1292
- const at = resolveDeadline(
1293
- raw,
1294
- key,
1295
- this.warnedDeadlines,
1296
- (m) => console.error(`[Deadline] \u274C ${m}`)
1406
+ })
1297
1407
  );
1298
- if (at === null) continue;
1299
- if (earliest === null || at < earliest) earliest = at;
1408
+ } catch (err) {
1409
+ console.error(`${this.label} tick error at tick=${this.tickCount}:`, err);
1410
+ this.tickCount++;
1411
+ return this.finish();
1300
1412
  }
1301
- return earliest;
1413
+ this.tickCount++;
1414
+ this.broadcastStateDelta(events, { tick: this.tickCount });
1415
+ this.debouncedPersist();
1416
+ return this.finish();
1302
1417
  }
1303
1418
  /**
1304
- * 次に起きるべき実時刻。締切と無人猶予の早い方。
1419
+ * 過ぎた締切の handler を実行する。heartbeat 起床でも呼ばれるが、期限が来ていなければ
1420
+ * 何もしない。
1305
1421
  *
1306
- * 停止中はシナリオの締切を予約しない (facet と同じ)。
1307
- */
1308
- nextWakeupWall() {
1309
- const candidates = [];
1310
- if (!this.frozen) {
1311
- const earliest = this.earliestDeadline();
1312
- if (earliest !== null) candidates.push(this.clock.toWall(earliest));
1313
- }
1314
- if (this.lastEmptyAtWall !== null && !this.clock.isFrozenBy("no-players")) {
1315
- candidates.push(this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS);
1316
- }
1317
- return candidates.length === 0 ? null : Math.min(...candidates);
1318
- }
1319
- /** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
1320
- syncWakeup() {
1321
- const next = this.nextWakeupWall();
1322
- if (next === this.wakeupAt) return;
1323
- if (this.wakeupTimer) clearTimeout(this.wakeupTimer);
1324
- this.wakeupTimer = null;
1325
- this.wakeupAt = next;
1326
- if (next === null) return;
1327
- this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
1328
- this.wakeupTimer.unref?.();
1329
- }
1330
- /** 猶予が満了していれば無人凍結に入る。 */
1331
- freezeIfEmptyGraceExpired() {
1332
- if (this.lastEmptyAtWall === null) return;
1333
- if (this.clock.isFrozenBy("no-players")) return;
1334
- if (Date.now() < this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS) return;
1335
- console.log(`[GameRoom] \u{1F9CA} \u7121\u4EBA\u7336\u4E88 ${NO_PLAYERS_GRACE_MS}ms \u6E80\u4E86 \u2192 \u6642\u8A08\u3092\u505C\u6B62`);
1336
- this.freeze("no-players");
1337
- }
1338
- /**
1339
- * 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
1422
+ * handler の直前に `at` を評価し直す。先に走った handler が別の締切を消したり
1423
+ * 先送りしたりしうるので、まとめて取ってから回すと消えたはずの締切まで撃ってしまう。
1424
+ * これにより at-least-once の重複発火も自然に弾かれる。
1340
1425
  *
1341
- * handler の直前に at を評価し直す (先に走った handler が別の締切を消しうる)。
1342
- * 失敗は握って捨てる。ログには必ず残す。
1426
+ * IMPORTANT: ゲームロジックの失敗は一時的障害より決定的バグの方が多く、素通しすると
1427
+ * alarm リトライでクラッシュを繰り返すだけなので、ここで握る (ログには必ず残す)。
1343
1428
  */
1344
- fireDue() {
1345
- this.wakeupTimer = null;
1346
- this.wakeupAt = null;
1429
+ alarm() {
1430
+ this.begin();
1347
1431
  this.freezeIfEmptyGraceExpired();
1348
- if (!this.gameState || !this.logic.deadlines || this.frozen) {
1349
- this.syncWakeup();
1350
- return;
1351
- }
1432
+ if (!this.gameState || !this.logic.deadlines || this.frozen) return this.finish();
1352
1433
  const time = this.gameTime();
1353
1434
  const events = [];
1354
1435
  const firedKeys = [];
@@ -1384,10 +1465,241 @@ var GameRoom = class _GameRoom {
1384
1465
  console.error(`[Deadline] \u274C ${key} \u3067\u4F8B\u5916`, err);
1385
1466
  }
1386
1467
  }
1387
- this.syncWakeup();
1388
- if (firedKeys.length === 0) return;
1389
- console.log(`[Deadline] \u23F0 ${firedKeys.length} \u4EF6\u767A\u706B: ${firedKeys.join(", ")}`);
1390
- this.broadcastStateDelta(events, {});
1468
+ if (firedKeys.length === 0) return this.finish();
1469
+ console.log(`[Deadline] \u23F0 ${firedKeys.length} \u4EF6\u767A\u706B: ${firedKeys.join(", ")}`);
1470
+ this.broadcastStateDelta(events, {});
1471
+ this.debouncedPersist();
1472
+ return this.finish();
1473
+ }
1474
+ /**
1475
+ * 切断 1 件の後処理。nextDeadline を返して transport の起床同期に使わせる。
1476
+ *
1477
+ * `remainingConnections` は transport しか数えられない。接続 0 になった時点から
1478
+ * 猶予を張り、満了を起床で見る。
1479
+ */
1480
+ disconnect(playerId, remainingConnections) {
1481
+ this.begin();
1482
+ delete this.playerInputs[playerId];
1483
+ if (remainingConnections === 0 && this.gameState !== null && this.lastEmptyAtWall === null) {
1484
+ this.lastEmptyAtWall = Date.now();
1485
+ this.persistClock();
1486
+ }
1487
+ return this.finish();
1488
+ }
1489
+ // ─── dev harness 用 (サブクラスからのみ触る) ─────────────
1490
+ /**
1491
+ * action を 1 件実行し、失敗したら throw する。dev harness の `sendAction` 専用。
1492
+ *
1493
+ * WS 経由 (`message`) は `__action_error` を返す形なので throw しない。ここだけが
1494
+ * 例外で伝える形なのは、呼び出し元が harness の JS で、返り値を握り潰されると
1495
+ * 失敗が画面にもコンソールにも出ないため。
1496
+ */
1497
+ async dispatchAction(actionName, payload, senderId) {
1498
+ this.begin();
1499
+ const outcome = await this.runAction(
1500
+ actionName,
1501
+ payload ?? {},
1502
+ senderId,
1503
+ {}
1504
+ );
1505
+ if (!outcome.ok) {
1506
+ throw outcome.cause instanceof Error ? outcome.cause : new Error(outcome.error ?? "unknown");
1507
+ }
1508
+ return this.finish();
1509
+ }
1510
+ /**
1511
+ * state を差し替えて配り直す。dev harness の state 流し込み専用。
1512
+ *
1513
+ * 同じオブジェクトを in-place で書き換えたあとに渡してもよい (merge / patch 用)。
1514
+ */
1515
+ publishState(next) {
1516
+ this.begin();
1517
+ this.gameState = next;
1518
+ this.broadcastStateDelta([], { tick: this.tickCount });
1519
+ this.debouncedPersist();
1520
+ return this.finish();
1521
+ }
1522
+ };
1523
+
1524
+ // src/dev-server/admin-state-patch.ts
1525
+ var MERGE_PATCH_ARRAY_REJECT = "[applyJsonMergePatch] cannot merge a non-array patch into an array target";
1526
+ function applyJsonMergePatch(target, patch) {
1527
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return;
1528
+ for (const [key, value] of Object.entries(patch)) {
1529
+ if (value === void 0) continue;
1530
+ if (value === null) {
1531
+ target[key] = null;
1532
+ continue;
1533
+ }
1534
+ if (Array.isArray(value)) {
1535
+ target[key] = value;
1536
+ continue;
1537
+ }
1538
+ if (typeof value === "object") {
1539
+ const existing = target[key];
1540
+ if (Array.isArray(existing)) {
1541
+ throw new Error(MERGE_PATCH_ARRAY_REJECT);
1542
+ }
1543
+ if (existing === null || typeof existing !== "object") {
1544
+ target[key] = value;
1545
+ continue;
1546
+ }
1547
+ applyJsonMergePatch(existing, value);
1548
+ continue;
1549
+ }
1550
+ target[key] = value;
1551
+ }
1552
+ }
1553
+ function applyJsonPatch(target, ops) {
1554
+ const ok = applyPatch(target, ops);
1555
+ if (!ok) {
1556
+ throw new Error("[applyJsonPatch] failed to apply one or more operations");
1557
+ }
1558
+ }
1559
+
1560
+ // src/dev-server/game-room.ts
1561
+ var GameRoom = class extends GameRoomCore {
1562
+ sockets = /* @__PURE__ */ new Set();
1563
+ attachments = /* @__PURE__ */ new WeakMap();
1564
+ tickTimer = null;
1565
+ tickPaused = false;
1566
+ /** dev は setTimeout。本番は storage の alarm。意味論は同じ。 */
1567
+ wakeupTimer = null;
1568
+ wakeupAt = null;
1569
+ snapshotSubscribers = /* @__PURE__ */ new Set();
1570
+ eventSubscribers = /* @__PURE__ */ new Set();
1571
+ /**
1572
+ * `players` を渡すと server 権威の roster になり、接続クエリの申告では上書きされない。
1573
+ * dev harness は manifest から組んだものを注入するので、旧世代 harness page の残タブが
1574
+ * reconnect しても roster を汚染できない (申告がズレていれば core が warn を出す)。
1575
+ */
1576
+ constructor(logic, players) {
1577
+ super(logic, { label: "[GameRoom]", storage: null, players });
1578
+ }
1579
+ // ─── Outcome の適用 ─────────────────────────────────────
1580
+ /**
1581
+ * core が返した「送るべきメッセージ」と tick / 起床の指示を transport へ反映する。
1582
+ *
1583
+ * core を呼んだら必ずここを通す。通し忘れると締切が armed にならず、
1584
+ * 「dev では発火しないのに本番では発火する」が生まれる。
1585
+ */
1586
+ applyOutcome(outcome, sender) {
1587
+ if (sender) for (const data of outcome.toSender) this.sendRaw(sender, data);
1588
+ for (const data of outcome.broadcast) {
1589
+ for (const ws of this.sockets) this.sendRaw(ws, data);
1590
+ }
1591
+ if (outcome.frozen) this.stopTickLoop();
1592
+ else this.ensureTickLoop(outcome);
1593
+ this.syncWakeup(outcome.nextDeadline);
1594
+ }
1595
+ sendRaw(ws, data) {
1596
+ try {
1597
+ ws.send(data);
1598
+ } catch {
1599
+ }
1600
+ }
1601
+ sendJson(ws, msg) {
1602
+ this.sendRaw(ws, JSON.stringify(msg));
1603
+ }
1604
+ /**
1605
+ * state を配ったあとに dev harness の購読者へ知らせる。
1606
+ * 本番 (facet) は override しないので、この経路ごと存在しない。
1607
+ */
1608
+ afterStateChanged(events) {
1609
+ this.snapshotSubscribers.forEach((cb) => {
1610
+ try {
1611
+ cb(this.gameState);
1612
+ } catch (err) {
1613
+ console.warn("[GameRoom] snapshot subscriber threw:", err);
1614
+ }
1615
+ });
1616
+ if (events.length === 0) return;
1617
+ const frozen = Object.freeze(events.slice());
1618
+ this.eventSubscribers.forEach((cb) => {
1619
+ try {
1620
+ cb(frozen);
1621
+ } catch (err) {
1622
+ console.warn("[GameRoom] event subscriber threw:", err);
1623
+ }
1624
+ });
1625
+ }
1626
+ // ─── Tick ループ ────────────────────────────────────────
1627
+ /**
1628
+ * 全クライアント切断で tickTimer は止まる (handleClose) が state は残るため、
1629
+ * 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
1630
+ */
1631
+ ensureTickLoop(outcome) {
1632
+ if (!outcome.started || outcome.tickRate <= 0) return;
1633
+ if (this.sockets.size === 0 || this.tickPaused || this.tickTimer) return;
1634
+ this.tickTimer = setInterval(() => this.onTickTimer(), 1e3 / outcome.tickRate);
1635
+ }
1636
+ stopTickLoop() {
1637
+ if (this.tickTimer) {
1638
+ clearInterval(this.tickTimer);
1639
+ this.tickTimer = null;
1640
+ }
1641
+ }
1642
+ /** interval は pause 中も回し続け、ここで捨てる (再開のたびに張り直さない)。 */
1643
+ onTickTimer() {
1644
+ if (this.tickPaused) return;
1645
+ this.applyOutcome(this.tick());
1646
+ }
1647
+ // ─── 起床 (本番の Durable Object alarm 相当) ──────────────
1648
+ /**
1649
+ * 起床時刻を core の宣言に合わせる。
1650
+ *
1651
+ * 本番 (GameHost.syncAlarm) は「現在の alarm より早い時だけ set し、後ろ倒しはしない」
1652
+ * という制約を持つが、それは workerd#6800 の evict 経路を踏まないための都合。
1653
+ * dev は素直に張り替える。
1654
+ */
1655
+ syncWakeup(next) {
1656
+ if (next === this.wakeupAt) return;
1657
+ if (this.wakeupTimer) clearTimeout(this.wakeupTimer);
1658
+ this.wakeupTimer = null;
1659
+ this.wakeupAt = next;
1660
+ if (next === null) return;
1661
+ this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
1662
+ this.wakeupTimer.unref?.();
1663
+ }
1664
+ fireDue() {
1665
+ this.wakeupTimer = null;
1666
+ this.wakeupAt = null;
1667
+ this.applyOutcome(this.alarm());
1668
+ }
1669
+ // ─── Connection ────────────────────────────────────────
1670
+ handleConnection(ws, url) {
1671
+ const playerId = url.searchParams.get("seatId");
1672
+ if (!playerId) {
1673
+ ws.close(1008, "Missing required query parameter: seatId");
1674
+ return;
1675
+ }
1676
+ const nickname = url.searchParams.get("nickname") ?? "Guest";
1677
+ const connectionId = randomUUID();
1678
+ console.log(
1679
+ `[GameRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`
1680
+ );
1681
+ const rosterParam = url.searchParams.get("players");
1682
+ const outcome = this.connect(playerId, rosterParam);
1683
+ this.sockets.add(ws);
1684
+ this.attachments.set(ws, { connectionId, playerId, nickname });
1685
+ ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
1686
+ ws.on("close", () => this.handleClose(ws));
1687
+ ws.on("error", () => this.handleClose(ws));
1688
+ this.sendJson(ws, { type: "__room_init", myId: playerId });
1689
+ this.applyOutcome(outcome, ws);
1690
+ }
1691
+ async handleMessage(ws, msg) {
1692
+ if (msg === "__ping") {
1693
+ this.sendRaw(ws, "__pong");
1694
+ return;
1695
+ }
1696
+ const attachment = this.attachments.get(ws);
1697
+ if (!attachment) return;
1698
+ try {
1699
+ this.applyOutcome(await this.message(attachment.playerId, msg), ws);
1700
+ } catch (err) {
1701
+ console.error(`[GameRoom] message relay error playerId=${attachment.playerId}:`, err);
1702
+ }
1391
1703
  }
1392
1704
  handleClose(ws) {
1393
1705
  const attachment = this.attachments.get(ws);
@@ -1397,14 +1709,8 @@ var GameRoom = class _GameRoom {
1397
1709
  );
1398
1710
  this.sockets.delete(ws);
1399
1711
  this.attachments.delete(ws);
1400
- delete this.playerInputs[attachment.playerId];
1401
- if (this.sockets.size === 0) {
1402
- this.stopTickLoop();
1403
- if (this.gameState !== null && this.lastEmptyAtWall === null) {
1404
- this.lastEmptyAtWall = Date.now();
1405
- this.syncWakeup();
1406
- }
1407
- }
1712
+ if (this.sockets.size === 0) this.stopTickLoop();
1713
+ this.applyOutcome(this.disconnect(attachment.playerId, this.sockets.size));
1408
1714
  }
1409
1715
  // ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
1410
1716
  admin() {
@@ -1412,21 +1718,22 @@ var GameRoom = class _GameRoom {
1412
1718
  getSnapshot: () => this.gameState,
1413
1719
  getRawState: () => this.gameState,
1414
1720
  setRawState: (next) => {
1415
- this.gameState = next;
1416
- this.broadcastStateDelta([], { tick: this.tickCount });
1721
+ this.applyOutcome(this.publishState(next));
1417
1722
  },
1418
1723
  mergeRawState: (patch) => {
1419
- if (!this.gameState) return;
1420
- applyJsonMergePatch(this.gameState, patch);
1421
- this.broadcastStateDelta([], { tick: this.tickCount });
1724
+ const state = this.gameState;
1725
+ if (!state) return;
1726
+ applyJsonMergePatch(state, patch);
1727
+ this.applyOutcome(this.publishState(state));
1422
1728
  },
1423
1729
  patchRawState: (ops) => {
1424
- if (!this.gameState) return;
1425
- applyJsonPatch(this.gameState, ops);
1426
- this.broadcastStateDelta([], { tick: this.tickCount });
1730
+ const state = this.gameState;
1731
+ if (!state) return;
1732
+ applyJsonPatch(state, ops);
1733
+ this.applyOutcome(this.publishState(state));
1427
1734
  },
1428
1735
  sendAction: async ({ as, type, payload }) => {
1429
- await this.dispatchAction(type, payload, as);
1736
+ this.applyOutcome(await this.dispatchAction(type, payload, as));
1430
1737
  },
1431
1738
  getSeed: () => this.seed,
1432
1739
  pauseTick: () => {
@@ -1442,38 +1749,13 @@ var GameRoom = class _GameRoom {
1442
1749
  if (!Number.isInteger(n) || n < 0) {
1443
1750
  throw new RangeError(`stepTick: n must be a non-negative integer (got ${n})`);
1444
1751
  }
1445
- for (let i = 0; i < n; i++) this.runOneTick();
1752
+ for (let i = 0; i < n; i++) this.applyOutcome(this.tick());
1446
1753
  },
1447
1754
  getCurrentTick: () => this.tickCount,
1448
1755
  isTickPaused: () => this.tickPaused,
1449
1756
  reset: (opts) => {
1450
- if (opts?.seed === "random") {
1451
- this.seed = Math.floor(Math.random() * 4294967295);
1452
- } else if (typeof opts?.seed === "number") {
1453
- this.seed = opts.seed;
1454
- }
1455
- this.random = new SeededRandomImpl(this.seed);
1456
- this.clock.restart();
1457
- this.pausedBy = null;
1458
- this.lastEmptyAtWall = null;
1459
- const setupTime = this.gameTime();
1460
- const args = this.setupArgs(this.random);
1461
- this.gameState = withGameClock(setupTime, () => this.logic.setup(args));
1462
- this.stateInitialized = true;
1463
- this.tickCount = 0;
1464
1757
  this.tickPaused = false;
1465
- this.seq = 0;
1466
- this.prevBroadcastState = structuredClone(this.gameState);
1467
- this.broadcastAll({
1468
- type: "__game_start",
1469
- state: this.gameState,
1470
- seed: this.seed,
1471
- seq: 0,
1472
- gameTime: this.gameTime()
1473
- });
1474
- this.syncWakeup();
1475
- this.notifySnapshotSubscribers();
1476
- this.ensureTickLoop();
1758
+ this.applyOutcome(this.resetGame(opts));
1477
1759
  },
1478
1760
  subscribeSnapshot: (cb) => {
1479
1761
  this.snapshotSubscribers.add(cb);
@@ -1491,314 +1773,6 @@ var GameRoom = class _GameRoom {
1491
1773
  }
1492
1774
  };
1493
1775
 
1494
- // src/dev-server/sync-room.ts
1495
- import { randomUUID as randomUUID2 } from "crypto";
1496
- var SERVER_TIME_SENTINEL = "__SERVER_TIME__";
1497
- function resolveServerTime(ops, now) {
1498
- for (const op of ops) {
1499
- if (op.value === SERVER_TIME_SENTINEL) {
1500
- op.value = now;
1501
- }
1502
- }
1503
- }
1504
- var SyncRoom = class _SyncRoom {
1505
- cachedState = null;
1506
- stateInitialized = false;
1507
- seq = 0;
1508
- patchesSinceReconciliation = 0;
1509
- static RECONCILIATION_INTERVAL = 30;
1510
- sockets = /* @__PURE__ */ new Set();
1511
- attachments = /* @__PURE__ */ new WeakMap();
1512
- snapshotSubscribers = /* @__PURE__ */ new Set();
1513
- broadcastFullState() {
1514
- if (!this.stateInitialized || this.cachedState === null) return;
1515
- const stateMsg = JSON.stringify({
1516
- type: "__state",
1517
- state: this.cachedState,
1518
- seq: this.seq,
1519
- serverTime: Date.now()
1520
- });
1521
- for (const peer of this.sockets) {
1522
- try {
1523
- peer.send(stateMsg);
1524
- } catch {
1525
- }
1526
- }
1527
- this.notifySnapshotSubscribers();
1528
- }
1529
- notifySnapshotSubscribers() {
1530
- this.snapshotSubscribers.forEach((cb) => {
1531
- try {
1532
- cb(this.cachedState);
1533
- } catch (err) {
1534
- console.warn("[SyncRoom] snapshot subscriber threw:", err);
1535
- }
1536
- });
1537
- }
1538
- handleConnection(ws, url) {
1539
- const playerId = url.searchParams.get("playerId");
1540
- if (!playerId) {
1541
- ws.close(1008, "Missing required query parameter: playerId");
1542
- return;
1543
- }
1544
- const connectionId = randomUUID2();
1545
- console.log(`[SyncRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId}`);
1546
- this.sockets.add(ws);
1547
- this.attachments.set(ws, { connectionId, playerId });
1548
- ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
1549
- ws.on("close", () => this.handleClose(ws));
1550
- ws.on("error", () => this.handleClose(ws));
1551
- ws.send(JSON.stringify({ type: "__room_init", myId: playerId }));
1552
- if (this.stateInitialized && this.cachedState !== null) {
1553
- ws.send(
1554
- JSON.stringify({
1555
- type: "__state",
1556
- state: this.cachedState,
1557
- seq: this.seq,
1558
- serverTime: Date.now()
1559
- })
1560
- );
1561
- }
1562
- }
1563
- handleMessage(ws, msg) {
1564
- if (msg === "__ping") {
1565
- try {
1566
- ws.send("__pong");
1567
- } catch {
1568
- }
1569
- return;
1570
- }
1571
- const attachment = this.attachments.get(ws);
1572
- if (!attachment) return;
1573
- const senderId = attachment.playerId;
1574
- let parsed;
1575
- try {
1576
- parsed = JSON.parse(msg);
1577
- } catch {
1578
- return;
1579
- }
1580
- const msgType = parsed.type;
1581
- console.log(`[SyncRoom] \u2B05 recv from=${senderId} type=${msgType}`);
1582
- if (msgType === "__init_state") {
1583
- if (this.stateInitialized) {
1584
- console.log(`[SyncRoom] __init_state skipped (already initialized)`);
1585
- return;
1586
- }
1587
- this.stateInitialized = true;
1588
- this.cachedState = parsed.state;
1589
- this.seq = 0;
1590
- this.patchesSinceReconciliation = 0;
1591
- console.log(`[SyncRoom] \u2705 State initialized`);
1592
- this.broadcastFullState();
1593
- return;
1594
- }
1595
- if (msgType === "__clear_state") {
1596
- this.cachedState = null;
1597
- this.stateInitialized = false;
1598
- this.seq = 0;
1599
- this.patchesSinceReconciliation = 0;
1600
- console.log(`[SyncRoom] \u{1F5D1} State cleared`);
1601
- const clearedMsg = JSON.stringify({ type: "__state_cleared" });
1602
- for (const peer of this.sockets) {
1603
- try {
1604
- peer.send(clearedMsg);
1605
- } catch {
1606
- }
1607
- }
1608
- this.notifySnapshotSubscribers();
1609
- return;
1610
- }
1611
- if (msgType === "__request_state") {
1612
- if (!this.stateInitialized || this.cachedState === null) return;
1613
- try {
1614
- ws.send(
1615
- JSON.stringify({
1616
- type: "__state",
1617
- state: this.cachedState,
1618
- seq: this.seq,
1619
- serverTime: Date.now()
1620
- })
1621
- );
1622
- } catch {
1623
- }
1624
- return;
1625
- }
1626
- if (msgType === "__patch") {
1627
- if (!this.stateInitialized || this.cachedState === null) return;
1628
- const ops = parsed.ops;
1629
- if (!ops || !Array.isArray(ops) || ops.length === 0) return;
1630
- const serverTime = Date.now();
1631
- resolveServerTime(ops, serverTime);
1632
- const workingCopy = structuredClone(this.cachedState);
1633
- const ok = applyPatch(workingCopy, ops);
1634
- if (!ok) {
1635
- try {
1636
- ws.send(JSON.stringify({ type: "__patch_failed", reason: "apply_error" }));
1637
- ws.send(
1638
- JSON.stringify({
1639
- type: "__state",
1640
- state: this.cachedState,
1641
- seq: this.seq,
1642
- serverTime: Date.now()
1643
- })
1644
- );
1645
- } catch {
1646
- }
1647
- return;
1648
- }
1649
- this.cachedState = workingCopy;
1650
- this.seq++;
1651
- const ackMsg = JSON.stringify({
1652
- type: "__patch_ack",
1653
- ops,
1654
- seq: this.seq,
1655
- serverTime,
1656
- senderId
1657
- });
1658
- for (const peer of this.sockets) {
1659
- try {
1660
- peer.send(ackMsg);
1661
- } catch {
1662
- }
1663
- }
1664
- this.notifySnapshotSubscribers();
1665
- this.patchesSinceReconciliation++;
1666
- if (this.patchesSinceReconciliation >= _SyncRoom.RECONCILIATION_INTERVAL) {
1667
- this.patchesSinceReconciliation = 0;
1668
- this.broadcastFullState();
1669
- }
1670
- return;
1671
- }
1672
- const outData = JSON.stringify({ ...parsed, __from: senderId });
1673
- if (parsed.__to && typeof parsed.__to === "string") {
1674
- for (const peer of this.sockets) {
1675
- const pa = this.attachments.get(peer);
1676
- if (pa && pa.playerId === parsed.__to) {
1677
- try {
1678
- peer.send(outData);
1679
- } catch {
1680
- }
1681
- }
1682
- }
1683
- } else {
1684
- for (const peer of this.sockets) {
1685
- if (peer === ws) continue;
1686
- try {
1687
- peer.send(outData);
1688
- } catch {
1689
- }
1690
- }
1691
- }
1692
- }
1693
- handleClose(ws) {
1694
- const attachment = this.attachments.get(ws);
1695
- if (attachment) {
1696
- console.log(
1697
- `[SyncRoom] \u274C Disconnected: connectionId=${attachment.connectionId} playerId=${attachment.playerId}`
1698
- );
1699
- }
1700
- this.sockets.delete(ws);
1701
- this.attachments.delete(ws);
1702
- }
1703
- // ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
1704
- admin() {
1705
- return {
1706
- getSnapshot: () => this.cachedState,
1707
- getRawState: () => this.cachedState,
1708
- setRawState: (next) => {
1709
- this.cachedState = next;
1710
- this.stateInitialized = true;
1711
- this.broadcastFullState();
1712
- },
1713
- mergeRawState: (patch) => {
1714
- if (!this.cachedState) return;
1715
- applyJsonMergePatch(this.cachedState, patch);
1716
- this.broadcastFullState();
1717
- },
1718
- patchRawState: (ops) => {
1719
- if (!this.cachedState) return;
1720
- applyJsonPatch(this.cachedState, ops);
1721
- this.broadcastFullState();
1722
- },
1723
- subscribeSnapshot: (cb) => {
1724
- this.snapshotSubscribers.add(cb);
1725
- return () => {
1726
- this.snapshotSubscribers.delete(cb);
1727
- };
1728
- }
1729
- };
1730
- }
1731
- };
1732
-
1733
- // src/dev-server/relay-room.ts
1734
- import { randomUUID as randomUUID3 } from "crypto";
1735
- var RelayRoom = class {
1736
- sockets = /* @__PURE__ */ new Set();
1737
- attachments = /* @__PURE__ */ new WeakMap();
1738
- handleConnection(ws, url) {
1739
- const playerId = url.searchParams.get("playerId");
1740
- if (!playerId) {
1741
- ws.close(1008, "Missing required query parameter: playerId");
1742
- return;
1743
- }
1744
- const connectionId = randomUUID3();
1745
- console.log(`[RelayRoom] \u{1F517} New connection: connectionId=${connectionId} playerId=${playerId}`);
1746
- this.sockets.add(ws);
1747
- this.attachments.set(ws, { connectionId, playerId });
1748
- ws.on("message", (raw) => this.handleMessage(ws, raw.toString()));
1749
- ws.on("close", () => this.handleClose(ws));
1750
- ws.on("error", () => this.handleClose(ws));
1751
- ws.send(JSON.stringify({ type: "__room_init", myId: playerId }));
1752
- }
1753
- handleMessage(ws, msg) {
1754
- if (msg === "__ping") {
1755
- try {
1756
- ws.send("__pong");
1757
- } catch {
1758
- }
1759
- return;
1760
- }
1761
- const attachment = this.attachments.get(ws);
1762
- if (!attachment) return;
1763
- const senderId = attachment.playerId;
1764
- let parsed;
1765
- try {
1766
- parsed = JSON.parse(msg);
1767
- } catch {
1768
- return;
1769
- }
1770
- console.log(`[RelayRoom] \u2B05 recv from=${senderId}`, JSON.stringify(parsed));
1771
- const outData = JSON.stringify({ ...parsed, __from: senderId });
1772
- if (parsed.__to && typeof parsed.__to === "string") {
1773
- for (const peer of this.sockets) {
1774
- const pa = this.attachments.get(peer);
1775
- if (pa && pa.playerId === parsed.__to) {
1776
- try {
1777
- peer.send(outData);
1778
- } catch {
1779
- }
1780
- }
1781
- }
1782
- } else {
1783
- for (const peer of this.sockets) {
1784
- if (peer === ws) continue;
1785
- try {
1786
- peer.send(outData);
1787
- } catch {
1788
- }
1789
- }
1790
- }
1791
- }
1792
- handleClose(ws) {
1793
- const attachment = this.attachments.get(ws);
1794
- if (attachment) {
1795
- console.log(`[RelayRoom] \u274C Disconnected: connectionId=${attachment.connectionId}`);
1796
- }
1797
- this.sockets.delete(ws);
1798
- this.attachments.delete(ws);
1799
- }
1800
- };
1801
-
1802
1776
  // src/dev-server/server.ts
1803
1777
  var CONTENT_TYPES = {
1804
1778
  html: "text/html; charset=utf-8",
@@ -1807,15 +1781,10 @@ var CONTENT_TYPES = {
1807
1781
  };
1808
1782
  function startHarnessServer(opts) {
1809
1783
  const gameRooms = /* @__PURE__ */ new Map();
1810
- const syncRooms = /* @__PURE__ */ new Map();
1811
- const relayRooms = /* @__PURE__ */ new Map();
1812
1784
  const resolveAdmin = () => {
1813
1785
  const preferredKey = `${opts.meta.revisionId}/${opts.meta.roomKey}`;
1814
- const gameRoom = gameRooms.get(preferredKey) ?? gameRooms.values().next().value;
1815
- if (gameRoom) return { kind: "game", game: gameRoom.admin() };
1816
- const syncRoom = syncRooms.values().next().value;
1817
- if (syncRoom) return { kind: "sync", sync: syncRoom.admin() };
1818
- return null;
1786
+ const room = gameRooms.get(preferredKey) ?? gameRooms.values().next().value;
1787
+ return room ? room.admin() : null;
1819
1788
  };
1820
1789
  const httpServer = createServer((req, res) => {
1821
1790
  handleHttpRequest(req, res, opts);
@@ -1843,32 +1812,6 @@ function startHarnessServer(opts) {
1843
1812
  });
1844
1813
  return;
1845
1814
  }
1846
- const syncMatch = pathname.match(/^\/ws\/sync\/([^/]+)$/);
1847
- if (syncMatch) {
1848
- const roomId = syncMatch[1];
1849
- wss.handleUpgrade(req, socket, head, (ws) => {
1850
- let room = syncRooms.get(roomId);
1851
- if (!room) {
1852
- room = new SyncRoom();
1853
- syncRooms.set(roomId, room);
1854
- }
1855
- room.handleConnection(ws, url);
1856
- });
1857
- return;
1858
- }
1859
- const relayMatch = pathname.match(/^\/ws\/rooms\/([^/]+)$/);
1860
- if (relayMatch) {
1861
- const roomId = relayMatch[1];
1862
- wss.handleUpgrade(req, socket, head, (ws) => {
1863
- let room = relayRooms.get(roomId);
1864
- if (!room) {
1865
- room = new RelayRoom();
1866
- relayRooms.set(roomId, room);
1867
- }
1868
- room.handleConnection(ws, url);
1869
- });
1870
- return;
1871
- }
1872
1815
  if (pathname === "/dev/admin") {
1873
1816
  wss.handleUpgrade(req, socket, head, (ws) => {
1874
1817
  handleAdminConnection(ws, resolveAdmin);
@@ -1879,7 +1822,7 @@ function startHarnessServer(opts) {
1879
1822
  });
1880
1823
  httpServer.listen(opts.port, opts.host ?? "127.0.0.1");
1881
1824
  return {
1882
- stop: () => new Promise((resolve7) => {
1825
+ stop: () => new Promise((resolve8) => {
1883
1826
  for (const ws of wss.clients) {
1884
1827
  try {
1885
1828
  ws.close();
@@ -1887,7 +1830,7 @@ function startHarnessServer(opts) {
1887
1830
  }
1888
1831
  }
1889
1832
  wss.close(() => {
1890
- httpServer.close(() => resolve7());
1833
+ httpServer.close(() => resolve8());
1891
1834
  });
1892
1835
  })
1893
1836
  };
@@ -2004,30 +1947,19 @@ function handleAdminConnection(ws, resolveAdmin) {
2004
1947
  });
2005
1948
  }
2006
1949
  function adminHasMethod(admin, method) {
2007
- if (admin.kind === "game" && admin.game) {
2008
- return typeof admin.game[method] === "function";
2009
- }
2010
- if (admin.kind === "sync" && admin.sync) {
2011
- return typeof admin.sync[method] === "function";
2012
- }
2013
- return false;
1950
+ return typeof admin[method] === "function";
2014
1951
  }
2015
1952
  async function invokeAdminMethod(admin, method, args) {
2016
- const target = admin.kind === "game" ? admin.game : admin.sync;
2017
- if (!target) return void 0;
2018
- const fn = target[method];
1953
+ const fn = admin[method];
2019
1954
  if (typeof fn !== "function") return void 0;
2020
- return await fn.apply(target, args);
1955
+ return await fn.apply(admin, args);
2021
1956
  }
2022
1957
  function subscribeAdmin(admin, kind, cb) {
2023
1958
  if (kind === "snapshot") {
2024
- const target = admin.kind === "game" ? admin.game : admin.sync;
2025
- if (!target) return null;
2026
- return target.subscribeSnapshot(cb);
1959
+ return admin.subscribeSnapshot(cb);
2027
1960
  }
2028
1961
  if (kind === "events") {
2029
- if (admin.kind !== "game" || !admin.game) return null;
2030
- return admin.game.subscribeEvents(cb);
1962
+ return admin.subscribeEvents(cb);
2031
1963
  }
2032
1964
  return null;
2033
1965
  }
@@ -2105,10 +2037,10 @@ function proxyUpgradeToScenario(req, socket, head, scenarioUrl) {
2105
2037
  import { build as build2 } from "esbuild";
2106
2038
  import { mkdtempSync, rmSync } from "fs";
2107
2039
  import { tmpdir } from "os";
2108
- import { join as join4, resolve as resolve3 } from "path";
2109
- import { pathToFileURL } from "url";
2040
+ import { join as join4, resolve as resolve4 } from "path";
2041
+ import { pathToFileURL as pathToFileURL2 } from "url";
2110
2042
  async function loadLogicFromPath(logicPath) {
2111
- const absPath = resolve3(logicPath);
2043
+ const absPath = resolve4(logicPath);
2112
2044
  const tempDir = mkdtempSync(join4(tmpdir(), "uzu-dev-logic-"));
2113
2045
  const outPath = join4(tempDir, "logic.mjs");
2114
2046
  await build2({
@@ -2120,7 +2052,7 @@ async function loadLogicFromPath(logicPath) {
2120
2052
  platform: "neutral",
2121
2053
  plugins: [uzuSdkServerStub]
2122
2054
  });
2123
- const mod = await import(pathToFileURL(outPath).href);
2055
+ const mod = await import(pathToFileURL2(outPath).href);
2124
2056
  const logic = mod.default ?? mod.logic;
2125
2057
  if (!logic) {
2126
2058
  throw new Error(
@@ -2185,12 +2117,12 @@ var DEFAULT_READY_PATTERN = "Local:\\s+(https?://[^\\s]+)";
2185
2117
  var DEFAULT_MIN_IFRAME_SHORT_EDGE = 360;
2186
2118
  async function runDevCommand() {
2187
2119
  const cwd = process.cwd();
2188
- const manifestPath = resolve4(cwd, "manifest.json");
2189
- if (!existsSync2(manifestPath)) {
2120
+ const manifestPath = resolve5(cwd, "manifest.json");
2121
+ if (!existsSync3(manifestPath)) {
2190
2122
  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");
2191
2123
  process.exit(1);
2192
2124
  }
2193
- const manifest = JSON.parse(readFileSync4(manifestPath, "utf-8"));
2125
+ const manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
2194
2126
  const playerCount = manifest.characters?.length ?? manifest.playerCount ?? 2;
2195
2127
  const orientation = manifest.orientation ?? "portrait";
2196
2128
  const devCommand = manifest.dev?.command ?? DEFAULT_DEV_COMMAND;
@@ -2198,14 +2130,14 @@ async function runDevCommand() {
2198
2130
  let loaded = null;
2199
2131
  if (manifest.serverActionLogicPath) {
2200
2132
  console.log(`[uzu dev] Building server logic from ${manifest.serverActionLogicPath}...`);
2201
- const logicPath = resolve4(cwd, manifest.serverActionLogicPath);
2133
+ const logicPath = resolve5(cwd, manifest.serverActionLogicPath);
2202
2134
  loaded = await loadLogicFromPath(logicPath);
2203
2135
  console.log("[uzu dev] Server logic ready");
2204
2136
  }
2205
2137
  const thisDir = dirname3(fileURLToPath3(import.meta.url));
2206
- const clientEntryJs = resolve4(thisDir, "harness", "client-entry.js");
2207
- const clientEntryTs = resolve4(thisDir, "harness", "client-entry.ts");
2208
- const clientEntry = existsSync2(clientEntryJs) ? clientEntryJs : existsSync2(clientEntryTs) ? clientEntryTs : null;
2138
+ const clientEntryJs = resolve5(thisDir, "harness", "client-entry.js");
2139
+ const clientEntryTs = resolve5(thisDir, "harness", "client-entry.ts");
2140
+ const clientEntry = existsSync3(clientEntryJs) ? clientEntryJs : existsSync3(clientEntryTs) ? clientEntryTs : null;
2209
2141
  if (!clientEntry) {
2210
2142
  console.error(`harness client entry \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${clientEntryJs}`);
2211
2143
  process.exit(1);
@@ -2301,7 +2233,7 @@ function stripAnsi(s) {
2301
2233
  return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
2302
2234
  }
2303
2235
  function watchForReady(child, pattern) {
2304
- return new Promise((resolve7, reject) => {
2236
+ return new Promise((resolve8, reject) => {
2305
2237
  const timeout = setTimeout(() => {
2306
2238
  reject(new Error("Timed out waiting for scenario dev server to become ready (30s)"));
2307
2239
  }, 3e4);
@@ -2317,7 +2249,7 @@ function watchForReady(child, pattern) {
2317
2249
  const url = /^https?:\/\//i.test(captured) ? captured : `http://localhost:${captured}`;
2318
2250
  resolved = true;
2319
2251
  clearTimeout(timeout);
2320
- resolve7(url.replace(/\/$/, ""));
2252
+ resolve8(url.replace(/\/$/, ""));
2321
2253
  };
2322
2254
  if (child.stdout) {
2323
2255
  const rl = createInterface({ input: child.stdout });
@@ -2344,14 +2276,14 @@ var MAX_HARNESS_PORT_ATTEMPTS = 100;
2344
2276
  var HARNESS_HOST = "0.0.0.0";
2345
2277
  async function findFreePort() {
2346
2278
  const net = await import("net");
2347
- const tryPort = (port) => new Promise((resolve7) => {
2279
+ const tryPort = (port) => new Promise((resolve8) => {
2348
2280
  const server = net.createServer();
2349
2281
  server.unref();
2350
- server.once("error", () => resolve7(null));
2282
+ server.once("error", () => resolve8(null));
2351
2283
  server.listen(port, HARNESS_HOST, () => {
2352
2284
  const address = server.address();
2353
2285
  const assigned = typeof address === "object" && address !== null ? address.port : port;
2354
- server.close(() => resolve7(assigned));
2286
+ server.close(() => resolve8(assigned));
2355
2287
  });
2356
2288
  });
2357
2289
  for (let i = 0; i < MAX_HARNESS_PORT_ATTEMPTS; i++) {
@@ -2365,8 +2297,8 @@ async function findFreePort() {
2365
2297
  }
2366
2298
 
2367
2299
  // src/sdk-version.ts
2368
- import { existsSync as existsSync3, readFileSync as readFileSync5 } from "fs";
2369
- import { dirname as dirname4, resolve as resolve5 } from "path";
2300
+ import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
2301
+ import { dirname as dirname4, resolve as resolve6 } from "path";
2370
2302
  var SDK_PACKAGE_PATHS = [
2371
2303
  ["@uzuhq", "code-sdk"],
2372
2304
  ["@uzupj", "uzu-sdk"]
@@ -2378,18 +2310,18 @@ var readInstalledSdkVersion = (cwd) => {
2378
2310
  "@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"
2379
2311
  );
2380
2312
  }
2381
- const version = JSON.parse(readFileSync5(pkgPath, "utf-8")).version;
2313
+ const version = JSON.parse(readFileSync6(pkgPath, "utf-8")).version;
2382
2314
  if (!version) {
2383
2315
  throw new Error(`${pkgPath} \u306B version \u304C\u3042\u308A\u307E\u305B\u3093\u3002`);
2384
2316
  }
2385
2317
  return version;
2386
2318
  };
2387
2319
  var findSdkPackageJson = (cwd) => {
2388
- let dir = resolve5(cwd);
2320
+ let dir = resolve6(cwd);
2389
2321
  for (; ; ) {
2390
2322
  const found = SDK_PACKAGE_PATHS.map(
2391
- (segments) => resolve5(dir, "node_modules", ...segments, "package.json")
2392
- ).find(existsSync3);
2323
+ (segments) => resolve6(dir, "node_modules", ...segments, "package.json")
2324
+ ).find(existsSync4);
2393
2325
  if (found) {
2394
2326
  return found;
2395
2327
  }
@@ -2450,7 +2382,7 @@ var failureHtml = (msg) => {
2450
2382
  </body>`;
2451
2383
  };
2452
2384
  var startLoopbackServer = async () => {
2453
- let resolve7 = null;
2385
+ let resolve8 = null;
2454
2386
  let reject = null;
2455
2387
  let delivered = false;
2456
2388
  let pending = null;
@@ -2487,7 +2419,7 @@ var startLoopbackServer = async () => {
2487
2419
  }
2488
2420
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2489
2421
  res.end(SUCCESS_HTML);
2490
- if (resolve7) resolve7(code);
2422
+ if (resolve8) resolve8(code);
2491
2423
  else pending = { code };
2492
2424
  });
2493
2425
  await new Promise((res, rej) => {
@@ -2509,7 +2441,7 @@ var startLoopbackServer = async () => {
2509
2441
  () => rej(new Error(`login timed out after ${timeoutMs}ms`)),
2510
2442
  timeoutMs
2511
2443
  );
2512
- resolve7 = (code) => {
2444
+ resolve8 = (code) => {
2513
2445
  clearTimeout(t);
2514
2446
  res(code);
2515
2447
  };
@@ -2557,8 +2489,8 @@ var runLoginFlow = async (env, onURL) => {
2557
2489
  // src/cli.ts
2558
2490
  var envOption = () => new Option("--env <env>", "\u63A5\u7D9A\u5148\u74B0\u5883 (dev | stg | prd)").default(DEFAULT_ENV).hideHelp();
2559
2491
  var readOwnCliVersion = () => {
2560
- const pkgPath = resolve6(dirname5(fileURLToPath4(import.meta.url)), "..", "package.json");
2561
- const version = JSON.parse(readFileSync6(pkgPath, "utf-8")).version;
2492
+ const pkgPath = resolve7(dirname5(fileURLToPath4(import.meta.url)), "..", "package.json");
2493
+ const version = JSON.parse(readFileSync7(pkgPath, "utf-8")).version;
2562
2494
  if (!version) {
2563
2495
  throw new Error("uzu-cli \u306E package.json \u306B version \u304C\u3042\u308A\u307E\u305B\u3093\u3002");
2564
2496
  }
@@ -2571,7 +2503,7 @@ program.command("create-2d-game").description("2D \u30A8\u30F3\u30B8\u30F3\u3092
2571
2503
  create2dGame(name);
2572
2504
  });
2573
2505
  program.command("dev").description(
2574
- "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"
2506
+ "scenario \u306E dev server \u3092\u8D77\u52D5\u3057\u3001 dev harness (iframe grid + HUD) \u3068 in-memory GameRoom \u3092\u63D0\u4F9B\u3059\u308B"
2575
2507
  ).action(async () => {
2576
2508
  try {
2577
2509
  await runDevCommand();
@@ -2583,12 +2515,12 @@ program.command("dev").description(
2583
2515
  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) => {
2584
2516
  const env = resolveEnv(options.env);
2585
2517
  const cwd = process.cwd();
2586
- const manifestPath = resolve6(cwd, "manifest.json");
2587
- if (!existsSync4(manifestPath)) {
2518
+ const manifestPath = resolve7(cwd, "manifest.json");
2519
+ if (!existsSync5(manifestPath)) {
2588
2520
  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");
2589
2521
  process.exit(1);
2590
2522
  }
2591
- const manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
2523
+ const manifest = JSON.parse(readFileSync7(manifestPath, "utf-8"));
2592
2524
  if (typeof manifest !== "object" || manifest === null) {
2593
2525
  console.error("manifest.json \u304C\u4E0D\u6B63\u306A\u5F62\u5F0F\u3067\u3059\u3002");
2594
2526
  process.exit(1);
@@ -2637,93 +2569,90 @@ program.command("publish").description("\u30B2\u30FC\u30E0\u3092\u30D3\u30EB\u30
2637
2569
  console.error(e instanceof Error ? e.message : String(e));
2638
2570
  process.exit(1);
2639
2571
  });
2572
+ let logicJs;
2573
+ if (manifest.serverActionLogicPath) {
2574
+ console.log("Building server logic...");
2575
+ try {
2576
+ logicJs = await buildAndVerifyServerLogic(cwd, manifest.serverActionLogicPath);
2577
+ } catch (e) {
2578
+ console.error(e instanceof Error ? e.message : String(e));
2579
+ process.exit(1);
2580
+ }
2581
+ }
2582
+ const isLocalIcon = (char) => !!char.icon && !char.icon.startsWith("http://") && !char.icon.startsWith("https://");
2583
+ const localIconPaths = (manifestCharacters ?? []).filter(isLocalIcon).map((char) => {
2584
+ const iconAbsPath = resolve7(cwd, char.icon);
2585
+ if (!existsSync5(iconAbsPath)) {
2586
+ console.error(`\u30A2\u30A4\u30B3\u30F3\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${char.icon}`);
2587
+ process.exit(1);
2588
+ }
2589
+ return iconAbsPath;
2590
+ });
2640
2591
  if (buildCommand) {
2641
2592
  console.log("Building...");
2642
2593
  execSync(buildCommand, { stdio: "inherit", cwd });
2643
2594
  }
2644
2595
  console.log("Creating ZIP...");
2645
- const absoluteOutputDir = resolve6(cwd, outputDir);
2646
- const zipPath = resolve6(cwd, "__zip__.zip");
2647
- await new Promise((res, reject) => {
2648
- const output = createWriteStream(zipPath);
2649
- const archive = new ZipArchive({ zlib: { level: 9 } });
2650
- output.on("close", () => res());
2651
- archive.on("error", (err) => reject(err));
2652
- archive.pipe(output);
2653
- archive.directory(absoluteOutputDir, false);
2654
- archive.finalize();
2655
- });
2656
- console.log(`Created: ${zipPath}`);
2657
- console.log("Uploading to R2...");
2658
- const { resourceId, revisionId, token } = await uploadGameToR2(env, zipPath, absoluteOutputDir);
2659
- console.log(`Uploaded to R2. revisionId: ${revisionId}, resourceId: ${resourceId}`);
2660
- if (manifest.serverActionLogicPath) {
2661
- const logicEntryPoint = resolve6(cwd, manifest.serverActionLogicPath);
2662
- const logicOutPath = resolve6(cwd, "__logic__.js");
2663
- try {
2664
- console.log("Building server logic...");
2665
- await buildServerLogic(logicEntryPoint, logicOutPath);
2666
- const logicModule = await import(pathToFileURL2(logicOutPath).href);
2667
- const resolvedLogic = logicModule.default ?? logicModule.logic;
2668
- if (!resolvedLogic) {
2669
- console.error(
2670
- `Error: ${manifest.serverActionLogicPath} must export a GameLogic object.
2671
- Use either: export default logic
2672
- Or: export const logic: GameLogic<State> = { ... }`
2673
- );
2674
- process.exit(1);
2675
- }
2676
- console.log("Uploading logic.js to R2...");
2677
- const logicJsContent = readFileSync6(logicOutPath, "utf-8");
2678
- await uploadLogicToR2(env, token, logicJsContent, { wireVersion: WIRE_VERSION });
2679
- } finally {
2680
- if (existsSync4(logicOutPath)) unlinkSync(logicOutPath);
2681
- }
2682
- }
2683
- let resolvedCharacters;
2684
- if (manifestCharacters) {
2685
- console.log("Resolving character icons...");
2686
- const isLocalIcon = (char) => !!char.icon && !char.icon.startsWith("http://") && !char.icon.startsWith("https://");
2687
- const localIconPaths = manifestCharacters.filter(isLocalIcon).map((char) => {
2688
- const iconAbsPath = resolve6(cwd, char.icon);
2689
- if (!existsSync4(iconAbsPath)) {
2690
- console.error(`\u30A2\u30A4\u30B3\u30F3\u30D5\u30A1\u30A4\u30EB\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${char.icon}`);
2691
- process.exit(1);
2692
- }
2693
- return iconAbsPath;
2596
+ const absoluteOutputDir = resolve7(cwd, outputDir);
2597
+ const zipPath = resolve7(cwd, "__zip__.zip");
2598
+ try {
2599
+ await new Promise((res, reject) => {
2600
+ const output = createWriteStream(zipPath);
2601
+ const archive = new ZipArchive({ zlib: { level: 9 } });
2602
+ output.on("close", () => res());
2603
+ archive.on("error", (err) => reject(err));
2604
+ archive.pipe(output);
2605
+ archive.directory(absoluteOutputDir, false);
2606
+ archive.finalize();
2694
2607
  });
2695
- const iconURLs = await uploadIconsToCfImages(env, token, localIconPaths);
2696
- resolvedCharacters = manifestCharacters.map((char) => {
2697
- if (!isLocalIcon(char)) return char;
2698
- const cfImagesUrl = iconURLs.get(resolve6(cwd, char.icon));
2699
- if (!cfImagesUrl) {
2700
- 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}`);
2701
- process.exit(1);
2608
+ console.log(`Created: ${zipPath}`);
2609
+ console.log("Uploading to R2...");
2610
+ const { resourceId, revisionId, token } = await uploadGameToR2(
2611
+ env,
2612
+ zipPath,
2613
+ absoluteOutputDir
2614
+ );
2615
+ console.log(`Uploaded to R2. revisionId: ${revisionId}, resourceId: ${resourceId}`);
2616
+ if (logicJs !== void 0) {
2617
+ console.log("Uploading logic.js to R2...");
2618
+ await uploadLogicToR2(env, token, logicJs, { wireVersion: WIRE_VERSION });
2619
+ }
2620
+ let resolvedCharacters;
2621
+ if (manifestCharacters) {
2622
+ console.log("Resolving character icons...");
2623
+ const iconURLs = await uploadIconsToCfImages(env, token, localIconPaths);
2624
+ resolvedCharacters = manifestCharacters.map((char) => {
2625
+ if (!isLocalIcon(char)) return char;
2626
+ const cfImagesUrl = iconURLs.get(resolve7(cwd, char.icon));
2627
+ if (!cfImagesUrl) {
2628
+ throw new Error(`\u30A2\u30A4\u30B3\u30F3\u306E\u30A2\u30C3\u30D7\u30ED\u30FC\u30C9\u7D50\u679C\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: ${char.icon}`);
2629
+ }
2630
+ console.log(`Uploaded icon: ${char.icon}`);
2631
+ return { ...char, icon: cfImagesUrl };
2632
+ });
2633
+ }
2634
+ console.log("Registering revision...");
2635
+ const revId = await registerRevision({
2636
+ env,
2637
+ gameId,
2638
+ resourceId,
2639
+ uploadToken: token,
2640
+ changeNotes: options.changeNotes || "",
2641
+ playerCount,
2642
+ orientation,
2643
+ characters: resolvedCharacters,
2644
+ manifest,
2645
+ publishMeta: {
2646
+ sdkVersion,
2647
+ cliVersion: OWN_CLI_VERSION,
2648
+ bridgeVersion: BRIDGE_VERSION,
2649
+ wireVersion: WIRE_VERSION
2702
2650
  }
2703
- console.log(`Uploaded icon: ${char.icon}`);
2704
- return { ...char, icon: cfImagesUrl };
2705
2651
  });
2652
+ console.log(`Registered revision: ${revId}`);
2653
+ } finally {
2654
+ if (existsSync5(zipPath)) unlinkSync2(zipPath);
2706
2655
  }
2707
- console.log("Registering revision...");
2708
- const revId = await registerRevision({
2709
- env,
2710
- gameId,
2711
- resourceId,
2712
- uploadToken: token,
2713
- changeNotes: options.changeNotes || "",
2714
- playerCount,
2715
- orientation,
2716
- characters: resolvedCharacters,
2717
- manifest,
2718
- publishMeta: {
2719
- sdkVersion,
2720
- cliVersion: OWN_CLI_VERSION,
2721
- bridgeVersion: BRIDGE_VERSION,
2722
- wireVersion: WIRE_VERSION
2723
- }
2724
- });
2725
- console.log(`Registered revision: ${revId}`);
2726
- unlinkSync(zipPath);
2727
2656
  const studioUrl = `https://${studioHost(env)}/ja/scenarios/global-id/${gameId}`;
2728
2657
  console.log("\nDone!");
2729
2658
  console.log(`UZU Studio: ${studioUrl}`);