@uzuhq/code-cli 0.5.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -548,6 +548,109 @@ import { WebSocketServer } from "ws";
548
548
  // src/dev-server/game-room.ts
549
549
  import { randomUUID } from "crypto";
550
550
 
551
+ // ../engine-core/src/game-time.ts
552
+ var plus = (t, d) => t + d;
553
+ var asGameTime = (ms) => ms;
554
+
555
+ // ../engine-core/src/game-clock.ts
556
+ var NO_PLAYERS_GRACE_MS = 5 * 6e4;
557
+ var GameClock = class {
558
+ startedAtWall = 0;
559
+ pausedTotalMs = 0;
560
+ pausedAtWall = null;
561
+ frozenBy = /* @__PURE__ */ new Set();
562
+ /** 時計を 0 から始め直す。`setup()` を呼ぶ直前に通す。 */
563
+ restart() {
564
+ this.startedAtWall = Date.now();
565
+ this.pausedTotalMs = 0;
566
+ this.pausedAtWall = null;
567
+ this.frozenBy.clear();
568
+ }
569
+ get frozen() {
570
+ return this.frozenBy.size > 0;
571
+ }
572
+ isFrozenBy(reason) {
573
+ return this.frozenBy.has(reason);
574
+ }
575
+ /**
576
+ * 現在のゲーム内時刻。停止中は `pausedAtWall` で凍るので同じ値を返し続ける。
577
+ *
578
+ * IMPORTANT: [withGameClock] の中で呼んではいけない。実装が `Date.now()` を読むので、
579
+ * 差し替え済みの時刻を実時刻として引き算し、大きく負の値になる。
580
+ * ハンドラへ渡す時刻は必ず外で 1 回取ってから渡すこと。
581
+ */
582
+ now() {
583
+ return asGameTime((this.pausedAtWall ?? Date.now()) - this.startedAtWall - this.pausedTotalMs);
584
+ }
585
+ /** ゲーム内時刻を、alarm / setTimeout が使う実時刻へ直す。 */
586
+ toWall(at) {
587
+ return at + this.startedAtWall + this.pausedTotalMs;
588
+ }
589
+ /** 実際に理由を足したら true。呼び出し側が永続化や tick 停止の要否に使う。 */
590
+ freeze(reason) {
591
+ if (this.frozenBy.has(reason)) return false;
592
+ const wasFrozen = this.frozen;
593
+ this.frozenBy.add(reason);
594
+ if (!wasFrozen) this.pausedAtWall = Date.now();
595
+ return true;
596
+ }
597
+ /** 実際に理由を外したら true。 */
598
+ unfreeze(reason) {
599
+ if (!this.frozenBy.delete(reason)) return false;
600
+ if (!this.frozen && this.pausedAtWall !== null) {
601
+ this.pausedTotalMs += Date.now() - this.pausedAtWall;
602
+ this.pausedAtWall = null;
603
+ }
604
+ return true;
605
+ }
606
+ snapshot() {
607
+ return {
608
+ startedAtWall: this.startedAtWall,
609
+ pausedTotalMs: this.pausedTotalMs,
610
+ pausedAtWall: this.pausedAtWall,
611
+ frozenBy: [...this.frozenBy]
612
+ };
613
+ }
614
+ /**
615
+ * 永続化した内容から復元する。欠損は「未停止」に倒す。
616
+ *
617
+ * 読めなかったせいで世界が止まったままになる方が、動き出すより悪い。
618
+ */
619
+ restore(saved) {
620
+ this.startedAtWall = saved.startedAtWall ?? 0;
621
+ this.pausedTotalMs = saved.pausedTotalMs ?? 0;
622
+ this.pausedAtWall = saved.pausedAtWall ?? null;
623
+ this.frozenBy.clear();
624
+ for (const reason of saved.frozenBy ?? []) this.frozenBy.add(reason);
625
+ if (this.frozen && this.pausedAtWall === null) this.frozenBy.clear();
626
+ }
627
+ };
628
+ function withGameClock(time, fn) {
629
+ const realNow = Date.now;
630
+ const RealDate = Date;
631
+ Date.now = () => time;
632
+ globalThis.Date = new Proxy(RealDate, {
633
+ construct: (target, args) => Reflect.construct(target, args.length === 0 ? [time] : args)
634
+ });
635
+ try {
636
+ return fn();
637
+ } finally {
638
+ globalThis.Date = RealDate;
639
+ Date.now = realNow;
640
+ }
641
+ }
642
+ function resolveDeadline(at, key, warned, report) {
643
+ if (at === null || at === void 0) return null;
644
+ if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
645
+ if (!warned.has(key)) {
646
+ warned.add(key);
647
+ report(
648
+ `deadline "${key}" \u306E at() \u304C\u7DE0\u5207\u306B\u4F7F\u3048\u306A\u3044\u5024\u3092\u8FD4\u3057\u305F: ${String(at)}\u3002\u3053\u306E\u7DE0\u5207\u306F\u4E8C\u5EA6\u3068\u767A\u706B\u3057\u306A\u3044\u3002\u5EC3\u6B62\u3055\u308C\u305F ctx.now \u3092\u8AAD\u3093\u3067\u3044\u306A\u3044\u304B\u78BA\u8A8D\u3059\u308B\u3053\u3068`
649
+ );
650
+ }
651
+ return null;
652
+ }
653
+
551
654
  // ../engine-core/src/json-patch.ts
552
655
  function escapePointer(key) {
553
656
  return key.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -673,8 +776,8 @@ function parseRoster(rosterParam) {
673
776
  }
674
777
 
675
778
  // ../engine-core/src/versions.ts
676
- var BRIDGE_VERSION = 1;
677
- var WIRE_VERSION = 1;
779
+ var BRIDGE_VERSION = 2;
780
+ var WIRE_VERSION = 2;
678
781
 
679
782
  // src/dev-server/admin-state-patch.ts
680
783
  var MERGE_PATCH_ARRAY_REJECT = "[applyJsonMergePatch] cannot merge a non-array patch into an array target";
@@ -736,6 +839,13 @@ var GameRoom = class _GameRoom {
736
839
  seq = 0;
737
840
  prevBroadcastState = null;
738
841
  static SNAPSHOT_INTERVAL = 20;
842
+ // ─── ゲーム内時計 ──────────────────────────────────────
843
+ // 算術と凍結の出入りは engine-core の GameClock (facet と共有)。
844
+ clock = new GameClock();
845
+ pausedBy = null;
846
+ lastEmptyAtWall = null;
847
+ /** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
848
+ warnedDeadlines = /* @__PURE__ */ new Set();
739
849
  sockets = /* @__PURE__ */ new Set();
740
850
  attachments = /* @__PURE__ */ new WeakMap();
741
851
  snapshotSubscribers = /* @__PURE__ */ new Set();
@@ -750,6 +860,42 @@ var GameRoom = class _GameRoom {
750
860
  );
751
861
  }
752
862
  }
863
+ // ─── ゲーム内時計 ───────────────────────────────────────
864
+ /** ゲーム開始からの ms。停止中は進まない。Unix epoch ではない。 */
865
+ gameTime() {
866
+ return this.clock.now();
867
+ }
868
+ get frozen() {
869
+ return this.clock.frozen;
870
+ }
871
+ /** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
872
+ timeCtx(time) {
873
+ return { time, after: (d) => plus(time, d) };
874
+ }
875
+ freeze(reason) {
876
+ if (!this.clock.freeze(reason)) return;
877
+ this.stopTickLoop();
878
+ this.syncWakeup();
879
+ }
880
+ unfreeze(reason) {
881
+ if (!this.clock.unfreeze(reason)) return;
882
+ this.syncWakeup();
883
+ this.ensureTickLoop();
884
+ }
885
+ /**
886
+ * 停止の状態を全接続へ配る。
887
+ *
888
+ * `no-players` は見せない。外れる条件が「接続した」なので、クライアントが見られる
889
+ * 状態では必ず外れている。
890
+ */
891
+ broadcastPauseState() {
892
+ this.broadcastAll({
893
+ type: "__pause_state",
894
+ frozen: this.clock.isFrozenBy("emergency-stop"),
895
+ by: this.pausedBy,
896
+ gameTime: this.gameTime()
897
+ });
898
+ }
753
899
  // ─── Broadcast ─────────────────────────────────────────
754
900
  broadcastAll(msg) {
755
901
  const data = JSON.stringify(msg);
@@ -768,7 +914,7 @@ var GameRoom = class _GameRoom {
768
914
  }
769
915
  broadcastStateDelta(events, extra) {
770
916
  this.seq++;
771
- const serverTime = Date.now();
917
+ const gameTime = this.gameTime();
772
918
  const fullType = extra.ack !== void 0 ? "__action_result" : "__tick";
773
919
  const deltaType = extra.ack !== void 0 ? "__action_result_delta" : "__tick_delta";
774
920
  const needFull = this.prevBroadcastState === null || this.seq % _GameRoom.SNAPSHOT_INTERVAL === 0;
@@ -778,7 +924,7 @@ var GameRoom = class _GameRoom {
778
924
  state: this.gameState,
779
925
  events,
780
926
  seq: this.seq,
781
- serverTime,
927
+ gameTime,
782
928
  ...extra
783
929
  });
784
930
  } else {
@@ -788,7 +934,7 @@ var GameRoom = class _GameRoom {
788
934
  patches,
789
935
  events,
790
936
  seq: this.seq,
791
- serverTime,
937
+ gameTime,
792
938
  ...extra
793
939
  });
794
940
  const fullPayload = JSON.stringify({
@@ -796,7 +942,7 @@ var GameRoom = class _GameRoom {
796
942
  state: this.gameState,
797
943
  events,
798
944
  seq: this.seq,
799
- serverTime,
945
+ gameTime,
800
946
  ...extra
801
947
  });
802
948
  const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
@@ -841,15 +987,20 @@ var GameRoom = class _GameRoom {
841
987
  return {
842
988
  players: this.players,
843
989
  seats: this.players,
844
- ctx: { random, now: Date.now() }
990
+ ctx: { random, ...this.timeCtx(this.gameTime()) }
845
991
  };
846
992
  }
847
993
  maybeStartGame() {
848
994
  if (this.gameState !== null) return;
849
995
  if (this.players.length === 0) return;
850
- this.seed = Date.now() & 4294967295;
996
+ this.seed = Math.floor(Math.random() * 4294967295);
851
997
  this.random = new SeededRandomImpl(this.seed);
852
- this.gameState = this.logic.setup(this.setupArgs(this.random));
998
+ this.clock.restart();
999
+ this.pausedBy = null;
1000
+ this.lastEmptyAtWall = null;
1001
+ const setupTime = this.gameTime();
1002
+ const args = this.setupArgs(this.random);
1003
+ this.gameState = withGameClock(setupTime, () => this.logic.setup(args));
853
1004
  this.stateInitialized = true;
854
1005
  this.tickCount = 0;
855
1006
  this.seq = 0;
@@ -859,9 +1010,11 @@ var GameRoom = class _GameRoom {
859
1010
  type: "__game_start",
860
1011
  state: this.gameState,
861
1012
  seed: this.seed,
862
- seq: 0
1013
+ seq: 0,
1014
+ gameTime: this.gameTime()
863
1015
  });
864
1016
  if (this.tickRate > 0) this.startTickLoop();
1017
+ this.syncWakeup();
865
1018
  this.notifySnapshotSubscribers();
866
1019
  }
867
1020
  startTickLoop() {
@@ -871,30 +1024,41 @@ var GameRoom = class _GameRoom {
871
1024
  // 全クライアント切断で tickTimer は止まる (handleClose) が gameState は残るため、
872
1025
  // 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
873
1026
  ensureTickLoop() {
874
- if (this.tickRate > 0 && this.gameState !== null && this.sockets.size > 0 && !this.tickPaused && !this.tickTimer) {
1027
+ if (this.tickRate > 0 && this.gameState !== null && this.sockets.size > 0 && !this.tickPaused && !this.frozen && !this.tickTimer) {
875
1028
  this.startTickLoop();
876
1029
  }
877
1030
  }
1031
+ stopTickLoop() {
1032
+ if (this.tickTimer) {
1033
+ clearInterval(this.tickTimer);
1034
+ this.tickTimer = null;
1035
+ }
1036
+ }
878
1037
  tick() {
879
- if (this.tickPaused) return;
1038
+ if (this.tickPaused || this.frozen) return;
880
1039
  this.runOneTick();
881
1040
  }
882
1041
  runOneTick() {
883
1042
  if (!this.gameState || !this.random) return;
884
1043
  const events = [];
885
1044
  const emit = (name, data) => events.push({ name, data: data ?? {} });
886
- const tickNow = Date.now();
1045
+ const time = this.gameTime();
1046
+ const random = this.random;
1047
+ const state = this.gameState;
887
1048
  try {
888
- this.logic.update({
889
- state: this.gameState,
890
- ctx: {
891
- random: this.random,
892
- tick: this.tickCount,
893
- now: tickNow,
894
- emit,
895
- playerInputs: this.playerInputs
896
- }
897
- });
1049
+ withGameClock(
1050
+ time,
1051
+ () => this.logic.update({
1052
+ state,
1053
+ ctx: {
1054
+ random,
1055
+ tick: this.tickCount,
1056
+ ...this.timeCtx(time),
1057
+ emit,
1058
+ playerInputs: this.playerInputs
1059
+ }
1060
+ })
1061
+ );
898
1062
  } catch (err) {
899
1063
  console.error(`[GameRoom] tick error at tick=${this.tickCount}:`, err);
900
1064
  this.tickCount++;
@@ -905,6 +1069,22 @@ var GameRoom = class _GameRoom {
905
1069
  this.broadcastStateDelta(events, { tick: this.tickCount });
906
1070
  }
907
1071
  // ─── Connection ────────────────────────────────────────
1072
+ /**
1073
+ * 復帰用の state メッセージ。
1074
+ *
1075
+ * 停止中に接続・再接続した端末が overlay を出せるよう `frozen` / `pausedBy` を載せる。
1076
+ */
1077
+ stateMessage() {
1078
+ return {
1079
+ type: "__state",
1080
+ state: this.gameState,
1081
+ tick: this.tickCount,
1082
+ seq: this.seq,
1083
+ gameTime: this.gameTime(),
1084
+ frozen: this.clock.isFrozenBy("emergency-stop"),
1085
+ pausedBy: this.pausedBy
1086
+ };
1087
+ }
908
1088
  handleConnection(ws, url) {
909
1089
  const playerId = url.searchParams.get("seatId");
910
1090
  if (!playerId) {
@@ -936,14 +1116,10 @@ var GameRoom = class _GameRoom {
936
1116
  type: "__room_init",
937
1117
  myId: playerId
938
1118
  });
1119
+ this.lastEmptyAtWall = null;
1120
+ this.unfreeze("no-players");
939
1121
  if (this.stateInitialized && this.gameState !== null) {
940
- this.sendTo(ws, {
941
- type: "__state",
942
- state: this.gameState,
943
- tick: this.tickCount,
944
- seq: this.seq,
945
- serverTime: Date.now()
946
- });
1122
+ this.sendTo(ws, this.stateMessage());
947
1123
  }
948
1124
  this.maybeStartGame();
949
1125
  this.ensureTickLoop();
@@ -967,6 +1143,31 @@ var GameRoom = class _GameRoom {
967
1143
  }
968
1144
  const msgType = parsed.type;
969
1145
  console.log(`[GameRoom] \u2B05 recv from=${senderId} type=${msgType}`);
1146
+ if (msgType === "__pause") {
1147
+ if (!this.gameState) return;
1148
+ if (!this.clock.isFrozenBy("emergency-stop")) {
1149
+ this.pausedBy = senderId;
1150
+ this.freeze("emergency-stop");
1151
+ console.log(`[GameRoom] \u23F8 \u7DCA\u6025\u505C\u6B62 by=${senderId} gameTime=${this.gameTime()}`);
1152
+ }
1153
+ this.broadcastPauseState();
1154
+ return;
1155
+ }
1156
+ if (msgType === "__resume") {
1157
+ if (this.clock.isFrozenBy("emergency-stop")) {
1158
+ this.unfreeze("emergency-stop");
1159
+ this.pausedBy = null;
1160
+ console.log(`[GameRoom] \u25B6\uFE0F \u7DCA\u6025\u505C\u6B62\u3092\u89E3\u9664 by=${senderId} gameTime=${this.gameTime()}`);
1161
+ }
1162
+ this.broadcastPauseState();
1163
+ return;
1164
+ }
1165
+ if (this.frozen && (msgType === "__action" || msgType === "__input")) {
1166
+ if (msgType === "__action") {
1167
+ this.sendTo(ws, { type: "__action_error", error: "Paused", seq: parsed.seq });
1168
+ }
1169
+ return;
1170
+ }
970
1171
  if (msgType === "__action") {
971
1172
  if (!this.gameState) {
972
1173
  this.sendTo(ws, {
@@ -992,13 +1193,7 @@ var GameRoom = class _GameRoom {
992
1193
  }
993
1194
  if (msgType === "__request_state") {
994
1195
  if (this.gameState !== null) {
995
- this.sendTo(ws, {
996
- type: "__state",
997
- state: this.gameState,
998
- tick: this.tickCount,
999
- seq: this.seq,
1000
- serverTime: Date.now()
1001
- });
1196
+ this.sendTo(ws, this.stateMessage());
1002
1197
  }
1003
1198
  return;
1004
1199
  }
@@ -1022,15 +1217,19 @@ var GameRoom = class _GameRoom {
1022
1217
  const emit = (name, data) => events.push({ name, data: data ?? {} });
1023
1218
  const serverEmit = emit;
1024
1219
  const snapshot = structuredClone(this.gameState);
1025
- const now = Date.now();
1220
+ const time = this.gameTime();
1026
1221
  try {
1027
1222
  if (plain && !legacyServerOnly) {
1028
- plain({
1029
- state: this.gameState,
1030
- payload: payload ?? {},
1031
- playerId: senderId,
1032
- ctx: { now, emit }
1033
- });
1223
+ const state = this.gameState;
1224
+ withGameClock(
1225
+ time,
1226
+ () => plain({
1227
+ state,
1228
+ payload: payload ?? {},
1229
+ playerId: senderId,
1230
+ ctx: { ...this.timeCtx(time), emit }
1231
+ })
1232
+ );
1034
1233
  }
1035
1234
  if (serverHandler) {
1036
1235
  await serverHandler({
@@ -1040,7 +1239,7 @@ var GameRoom = class _GameRoom {
1040
1239
  ctx: {
1041
1240
  tick: this.tickCount,
1042
1241
  random: this.random ?? new SeededRandomImpl(this.seed),
1043
- now,
1242
+ ...this.timeCtx(time),
1044
1243
  emit: serverEmit
1045
1244
  }
1046
1245
  });
@@ -1057,28 +1256,50 @@ var GameRoom = class _GameRoom {
1057
1256
  wakeupTimer = null;
1058
1257
  wakeupAt = null;
1059
1258
  /**
1060
- * 宣言された締切のうち最も早い時刻。締切が無ければ null。
1259
+ * 宣言された締切のうち最も早いゲーム内時刻。締切が無ければ null。
1061
1260
  * state から毎回導出するので保存しない。
1062
1261
  */
1063
- nextDeadline() {
1262
+ earliestDeadline() {
1064
1263
  if (!this.gameState || !this.logic.deadlines) return null;
1065
1264
  let earliest = null;
1066
1265
  for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
1067
- let at;
1266
+ let raw;
1068
1267
  try {
1069
- at = deadline.at({ state: this.gameState });
1268
+ raw = deadline.at({ state: this.gameState });
1070
1269
  } catch (err) {
1071
1270
  console.error(`[Deadline] \u274C ${key}.at() \u3067\u4F8B\u5916`, err);
1072
1271
  continue;
1073
1272
  }
1074
- if (typeof at !== "number" || !Number.isFinite(at)) continue;
1273
+ const at = resolveDeadline(
1274
+ raw,
1275
+ key,
1276
+ this.warnedDeadlines,
1277
+ (m) => console.error(`[Deadline] \u274C ${m}`)
1278
+ );
1279
+ if (at === null) continue;
1075
1280
  if (earliest === null || at < earliest) earliest = at;
1076
1281
  }
1077
1282
  return earliest;
1078
1283
  }
1284
+ /**
1285
+ * 次に起きるべき実時刻。締切と無人猶予の早い方。
1286
+ *
1287
+ * 停止中はシナリオの締切を予約しない (facet と同じ)。
1288
+ */
1289
+ nextWakeupWall() {
1290
+ const candidates = [];
1291
+ if (!this.frozen) {
1292
+ const earliest = this.earliestDeadline();
1293
+ if (earliest !== null) candidates.push(this.clock.toWall(earliest));
1294
+ }
1295
+ if (this.lastEmptyAtWall !== null && !this.clock.isFrozenBy("no-players")) {
1296
+ candidates.push(this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS);
1297
+ }
1298
+ return candidates.length === 0 ? null : Math.min(...candidates);
1299
+ }
1079
1300
  /** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
1080
1301
  syncWakeup() {
1081
- const next = this.nextDeadline();
1302
+ const next = this.nextWakeupWall();
1082
1303
  if (next === this.wakeupAt) return;
1083
1304
  if (this.wakeupTimer) clearTimeout(this.wakeupTimer);
1084
1305
  this.wakeupTimer = null;
@@ -1087,6 +1308,14 @@ var GameRoom = class _GameRoom {
1087
1308
  this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
1088
1309
  this.wakeupTimer.unref?.();
1089
1310
  }
1311
+ /** 猶予が満了していれば無人凍結に入る。 */
1312
+ freezeIfEmptyGraceExpired() {
1313
+ if (this.lastEmptyAtWall === null) return;
1314
+ if (this.clock.isFrozenBy("no-players")) return;
1315
+ if (Date.now() < this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS) return;
1316
+ console.log(`[GameRoom] \u{1F9CA} \u7121\u4EBA\u7336\u4E88 ${NO_PLAYERS_GRACE_MS}ms \u6E80\u4E86 \u2192 \u6642\u8A08\u3092\u505C\u6B62`);
1317
+ this.freeze("no-players");
1318
+ }
1090
1319
  /**
1091
1320
  * 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
1092
1321
  *
@@ -1096,8 +1325,12 @@ var GameRoom = class _GameRoom {
1096
1325
  fireDue() {
1097
1326
  this.wakeupTimer = null;
1098
1327
  this.wakeupAt = null;
1099
- if (!this.gameState || !this.logic.deadlines) return;
1100
- const now = Date.now();
1328
+ this.freezeIfEmptyGraceExpired();
1329
+ if (!this.gameState || !this.logic.deadlines || this.frozen) {
1330
+ this.syncWakeup();
1331
+ return;
1332
+ }
1333
+ const time = this.gameTime();
1101
1334
  const events = [];
1102
1335
  const firedKeys = [];
1103
1336
  for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
@@ -1106,17 +1339,25 @@ var GameRoom = class _GameRoom {
1106
1339
  const pending = [];
1107
1340
  let snapshot = null;
1108
1341
  try {
1109
- const at = deadline.at({ state });
1110
- if (typeof at !== "number" || !Number.isFinite(at) || at > now) continue;
1342
+ const at = resolveDeadline(
1343
+ deadline.at({ state }),
1344
+ key,
1345
+ this.warnedDeadlines,
1346
+ (m) => console.error(`[Deadline] \u274C ${m}`)
1347
+ );
1348
+ if (at === null || at > time) continue;
1111
1349
  snapshot = structuredClone(state);
1112
- deadline.handler({
1113
- state,
1114
- ctx: {
1115
- now,
1116
- random: this.random ?? new SeededRandomImpl(this.seed),
1117
- emit: (name, data) => pending.push({ name, data: data ?? {} })
1118
- }
1119
- });
1350
+ withGameClock(
1351
+ time,
1352
+ () => deadline.handler({
1353
+ state,
1354
+ ctx: {
1355
+ ...this.timeCtx(time),
1356
+ random: this.random ?? new SeededRandomImpl(this.seed),
1357
+ emit: (name, data) => pending.push({ name, data: data ?? {} })
1358
+ }
1359
+ })
1360
+ );
1120
1361
  events.push(...pending);
1121
1362
  firedKeys.push(key);
1122
1363
  } catch (err) {
@@ -1138,9 +1379,12 @@ var GameRoom = class _GameRoom {
1138
1379
  this.sockets.delete(ws);
1139
1380
  this.attachments.delete(ws);
1140
1381
  delete this.playerInputs[attachment.playerId];
1141
- if (this.sockets.size === 0 && this.tickTimer) {
1142
- clearInterval(this.tickTimer);
1143
- this.tickTimer = null;
1382
+ if (this.sockets.size === 0) {
1383
+ this.stopTickLoop();
1384
+ if (this.gameState !== null && this.lastEmptyAtWall === null) {
1385
+ this.lastEmptyAtWall = Date.now();
1386
+ this.syncWakeup();
1387
+ }
1144
1388
  }
1145
1389
  }
1146
1390
  // ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
@@ -1190,7 +1434,13 @@ var GameRoom = class _GameRoom {
1190
1434
  this.seed = opts.seed;
1191
1435
  }
1192
1436
  this.random = new SeededRandomImpl(this.seed);
1193
- this.gameState = this.logic.setup(this.setupArgs(this.random));
1437
+ this.clock.restart();
1438
+ this.pausedBy = null;
1439
+ this.lastEmptyAtWall = null;
1440
+ const setupTime = this.gameTime();
1441
+ const args = this.setupArgs(this.random);
1442
+ this.gameState = withGameClock(setupTime, () => this.logic.setup(args));
1443
+ this.stateInitialized = true;
1194
1444
  this.tickCount = 0;
1195
1445
  this.tickPaused = false;
1196
1446
  this.seq = 0;
@@ -1199,8 +1449,10 @@ var GameRoom = class _GameRoom {
1199
1449
  type: "__game_start",
1200
1450
  state: this.gameState,
1201
1451
  seed: this.seed,
1202
- seq: 0
1452
+ seq: 0,
1453
+ gameTime: this.gameTime()
1203
1454
  });
1455
+ this.syncWakeup();
1204
1456
  this.notifySnapshotSubscribers();
1205
1457
  this.ensureTickLoop();
1206
1458
  },
@@ -6,6 +6,9 @@ var DEFAULT_ICON_URLS = [
6
6
  "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original"
7
7
  ];
8
8
 
9
+ // ../engine-core/src/game-clock.ts
10
+ var NO_PLAYERS_GRACE_MS = 5 * 6e4;
11
+
9
12
  // ../engine-core/src/server-only.ts
10
13
  function serverOnly(handler) {
11
14
  return Object.assign(handler, { __serverOnly: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-cli",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {