@uzuhq/code-cli 0.5.10 → 0.6.1

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,111 @@ 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 toMs = (t) => t;
553
+ var plus = (t, d) => toMs(t) + d;
554
+ var asGameTime = (ms) => ms;
555
+
556
+ // ../engine-core/src/game-clock.ts
557
+ var NO_PLAYERS_GRACE_MS = 5 * 6e4;
558
+ var GameClock = class {
559
+ startedAtWall = 0;
560
+ pausedTotalMs = 0;
561
+ pausedAtWall = null;
562
+ frozenBy = /* @__PURE__ */ new Set();
563
+ /** 時計を 0 から始め直す。`setup()` を呼ぶ直前に通す。 */
564
+ restart() {
565
+ this.startedAtWall = Date.now();
566
+ this.pausedTotalMs = 0;
567
+ this.pausedAtWall = null;
568
+ this.frozenBy.clear();
569
+ }
570
+ get frozen() {
571
+ return this.frozenBy.size > 0;
572
+ }
573
+ isFrozenBy(reason) {
574
+ return this.frozenBy.has(reason);
575
+ }
576
+ /**
577
+ * 現在のゲーム内時刻。停止中は `pausedAtWall` で凍るので同じ値を返し続ける。
578
+ *
579
+ * IMPORTANT: [withGameClock] の中で呼んではいけない。実装が `Date.now()` を読むので、
580
+ * 差し替え済みの時刻を実時刻として引き算し、大きく負の値になる。
581
+ * ハンドラへ渡す時刻は必ず外で 1 回取ってから渡すこと。
582
+ */
583
+ now() {
584
+ return asGameTime((this.pausedAtWall ?? Date.now()) - this.startedAtWall - this.pausedTotalMs);
585
+ }
586
+ /** ゲーム内時刻を、alarm / setTimeout が使う実時刻へ直す。 */
587
+ toWall(at) {
588
+ return toMs(at) + this.startedAtWall + this.pausedTotalMs;
589
+ }
590
+ /** 実際に理由を足したら true。呼び出し側が永続化や tick 停止の要否に使う。 */
591
+ freeze(reason) {
592
+ if (this.frozenBy.has(reason)) return false;
593
+ const wasFrozen = this.frozen;
594
+ this.frozenBy.add(reason);
595
+ if (!wasFrozen) this.pausedAtWall = Date.now();
596
+ return true;
597
+ }
598
+ /** 実際に理由を外したら true。 */
599
+ unfreeze(reason) {
600
+ if (!this.frozenBy.delete(reason)) return false;
601
+ if (!this.frozen && this.pausedAtWall !== null) {
602
+ this.pausedTotalMs += Date.now() - this.pausedAtWall;
603
+ this.pausedAtWall = null;
604
+ }
605
+ return true;
606
+ }
607
+ snapshot() {
608
+ return {
609
+ startedAtWall: this.startedAtWall,
610
+ pausedTotalMs: this.pausedTotalMs,
611
+ pausedAtWall: this.pausedAtWall,
612
+ frozenBy: [...this.frozenBy]
613
+ };
614
+ }
615
+ /**
616
+ * 永続化した内容から復元する。欠損は「未停止」に倒す。
617
+ *
618
+ * 読めなかったせいで世界が止まったままになる方が、動き出すより悪い。
619
+ */
620
+ restore(saved) {
621
+ this.startedAtWall = saved.startedAtWall ?? 0;
622
+ this.pausedTotalMs = saved.pausedTotalMs ?? 0;
623
+ this.pausedAtWall = saved.pausedAtWall ?? null;
624
+ this.frozenBy.clear();
625
+ for (const reason of saved.frozenBy ?? []) this.frozenBy.add(reason);
626
+ if (this.frozen && this.pausedAtWall === null) this.frozenBy.clear();
627
+ }
628
+ };
629
+ function withGameClock(time, fn) {
630
+ const realNow = Date.now;
631
+ const RealDate = Date;
632
+ const ms = toMs(time);
633
+ Date.now = () => ms;
634
+ globalThis.Date = new Proxy(RealDate, {
635
+ construct: (target, args) => Reflect.construct(target, args.length === 0 ? [ms] : args)
636
+ });
637
+ try {
638
+ return fn();
639
+ } finally {
640
+ globalThis.Date = RealDate;
641
+ Date.now = realNow;
642
+ }
643
+ }
644
+ function resolveDeadline(at, key, warned, report) {
645
+ if (at === null || at === void 0) return null;
646
+ if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
647
+ if (!warned.has(key)) {
648
+ warned.add(key);
649
+ report(
650
+ `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`
651
+ );
652
+ }
653
+ return null;
654
+ }
655
+
551
656
  // ../engine-core/src/json-patch.ts
552
657
  function escapePointer(key) {
553
658
  return key.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -673,8 +778,8 @@ function parseRoster(rosterParam) {
673
778
  }
674
779
 
675
780
  // ../engine-core/src/versions.ts
676
- var BRIDGE_VERSION = 1;
677
- var WIRE_VERSION = 1;
781
+ var BRIDGE_VERSION = 2;
782
+ var WIRE_VERSION = 2;
678
783
 
679
784
  // src/dev-server/admin-state-patch.ts
680
785
  var MERGE_PATCH_ARRAY_REJECT = "[applyJsonMergePatch] cannot merge a non-array patch into an array target";
@@ -736,6 +841,13 @@ var GameRoom = class _GameRoom {
736
841
  seq = 0;
737
842
  prevBroadcastState = null;
738
843
  static SNAPSHOT_INTERVAL = 20;
844
+ // ─── ゲーム内時計 ──────────────────────────────────────
845
+ // 算術と凍結の出入りは engine-core の GameClock (facet と共有)。
846
+ clock = new GameClock();
847
+ pausedBy = null;
848
+ lastEmptyAtWall = null;
849
+ /** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
850
+ warnedDeadlines = /* @__PURE__ */ new Set();
739
851
  sockets = /* @__PURE__ */ new Set();
740
852
  attachments = /* @__PURE__ */ new WeakMap();
741
853
  snapshotSubscribers = /* @__PURE__ */ new Set();
@@ -750,6 +862,42 @@ var GameRoom = class _GameRoom {
750
862
  );
751
863
  }
752
864
  }
865
+ // ─── ゲーム内時計 ───────────────────────────────────────
866
+ /** ゲーム開始からの ms。停止中は進まない。Unix epoch ではない。 */
867
+ gameTime() {
868
+ return this.clock.now();
869
+ }
870
+ get frozen() {
871
+ return this.clock.frozen;
872
+ }
873
+ /** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
874
+ timeCtx(time) {
875
+ return { time, after: (d) => plus(time, d) };
876
+ }
877
+ freeze(reason) {
878
+ if (!this.clock.freeze(reason)) return;
879
+ this.stopTickLoop();
880
+ this.syncWakeup();
881
+ }
882
+ unfreeze(reason) {
883
+ if (!this.clock.unfreeze(reason)) return;
884
+ this.syncWakeup();
885
+ this.ensureTickLoop();
886
+ }
887
+ /**
888
+ * 停止の状態を全接続へ配る。
889
+ *
890
+ * `no-players` は見せない。外れる条件が「接続した」なので、クライアントが見られる
891
+ * 状態では必ず外れている。
892
+ */
893
+ broadcastPauseState() {
894
+ this.broadcastAll({
895
+ type: "__pause_state",
896
+ frozen: this.clock.isFrozenBy("emergency-stop"),
897
+ by: this.pausedBy,
898
+ gameTime: this.gameTime()
899
+ });
900
+ }
753
901
  // ─── Broadcast ─────────────────────────────────────────
754
902
  broadcastAll(msg) {
755
903
  const data = JSON.stringify(msg);
@@ -768,7 +916,7 @@ var GameRoom = class _GameRoom {
768
916
  }
769
917
  broadcastStateDelta(events, extra) {
770
918
  this.seq++;
771
- const serverTime = Date.now();
919
+ const gameTime = this.gameTime();
772
920
  const fullType = extra.ack !== void 0 ? "__action_result" : "__tick";
773
921
  const deltaType = extra.ack !== void 0 ? "__action_result_delta" : "__tick_delta";
774
922
  const needFull = this.prevBroadcastState === null || this.seq % _GameRoom.SNAPSHOT_INTERVAL === 0;
@@ -778,7 +926,7 @@ var GameRoom = class _GameRoom {
778
926
  state: this.gameState,
779
927
  events,
780
928
  seq: this.seq,
781
- serverTime,
929
+ gameTime,
782
930
  ...extra
783
931
  });
784
932
  } else {
@@ -788,7 +936,7 @@ var GameRoom = class _GameRoom {
788
936
  patches,
789
937
  events,
790
938
  seq: this.seq,
791
- serverTime,
939
+ gameTime,
792
940
  ...extra
793
941
  });
794
942
  const fullPayload = JSON.stringify({
@@ -796,7 +944,7 @@ var GameRoom = class _GameRoom {
796
944
  state: this.gameState,
797
945
  events,
798
946
  seq: this.seq,
799
- serverTime,
947
+ gameTime,
800
948
  ...extra
801
949
  });
802
950
  const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
@@ -841,7 +989,7 @@ var GameRoom = class _GameRoom {
841
989
  return {
842
990
  players: this.players,
843
991
  seats: this.players,
844
- ctx: { random, now: Date.now() }
992
+ ctx: { random, ...this.timeCtx(this.gameTime()) }
845
993
  };
846
994
  }
847
995
  maybeStartGame() {
@@ -849,7 +997,12 @@ var GameRoom = class _GameRoom {
849
997
  if (this.players.length === 0) return;
850
998
  this.seed = Math.floor(Math.random() * 4294967295);
851
999
  this.random = new SeededRandomImpl(this.seed);
852
- this.gameState = this.logic.setup(this.setupArgs(this.random));
1000
+ this.clock.restart();
1001
+ this.pausedBy = null;
1002
+ this.lastEmptyAtWall = null;
1003
+ const setupTime = this.gameTime();
1004
+ const args = this.setupArgs(this.random);
1005
+ this.gameState = withGameClock(setupTime, () => this.logic.setup(args));
853
1006
  this.stateInitialized = true;
854
1007
  this.tickCount = 0;
855
1008
  this.seq = 0;
@@ -859,9 +1012,11 @@ var GameRoom = class _GameRoom {
859
1012
  type: "__game_start",
860
1013
  state: this.gameState,
861
1014
  seed: this.seed,
862
- seq: 0
1015
+ seq: 0,
1016
+ gameTime: this.gameTime()
863
1017
  });
864
1018
  if (this.tickRate > 0) this.startTickLoop();
1019
+ this.syncWakeup();
865
1020
  this.notifySnapshotSubscribers();
866
1021
  }
867
1022
  startTickLoop() {
@@ -871,30 +1026,41 @@ var GameRoom = class _GameRoom {
871
1026
  // 全クライアント切断で tickTimer は止まる (handleClose) が gameState は残るため、
872
1027
  // 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
873
1028
  ensureTickLoop() {
874
- if (this.tickRate > 0 && this.gameState !== null && this.sockets.size > 0 && !this.tickPaused && !this.tickTimer) {
1029
+ if (this.tickRate > 0 && this.gameState !== null && this.sockets.size > 0 && !this.tickPaused && !this.frozen && !this.tickTimer) {
875
1030
  this.startTickLoop();
876
1031
  }
877
1032
  }
1033
+ stopTickLoop() {
1034
+ if (this.tickTimer) {
1035
+ clearInterval(this.tickTimer);
1036
+ this.tickTimer = null;
1037
+ }
1038
+ }
878
1039
  tick() {
879
- if (this.tickPaused) return;
1040
+ if (this.tickPaused || this.frozen) return;
880
1041
  this.runOneTick();
881
1042
  }
882
1043
  runOneTick() {
883
1044
  if (!this.gameState || !this.random) return;
884
1045
  const events = [];
885
1046
  const emit = (name, data) => events.push({ name, data: data ?? {} });
886
- const tickNow = Date.now();
1047
+ const time = this.gameTime();
1048
+ const random = this.random;
1049
+ const state = this.gameState;
887
1050
  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
- });
1051
+ withGameClock(
1052
+ time,
1053
+ () => this.logic.update({
1054
+ state,
1055
+ ctx: {
1056
+ random,
1057
+ tick: this.tickCount,
1058
+ ...this.timeCtx(time),
1059
+ emit,
1060
+ playerInputs: this.playerInputs
1061
+ }
1062
+ })
1063
+ );
898
1064
  } catch (err) {
899
1065
  console.error(`[GameRoom] tick error at tick=${this.tickCount}:`, err);
900
1066
  this.tickCount++;
@@ -905,6 +1071,22 @@ var GameRoom = class _GameRoom {
905
1071
  this.broadcastStateDelta(events, { tick: this.tickCount });
906
1072
  }
907
1073
  // ─── Connection ────────────────────────────────────────
1074
+ /**
1075
+ * 復帰用の state メッセージ。
1076
+ *
1077
+ * 停止中に接続・再接続した端末が overlay を出せるよう `frozen` / `pausedBy` を載せる。
1078
+ */
1079
+ stateMessage() {
1080
+ return {
1081
+ type: "__state",
1082
+ state: this.gameState,
1083
+ tick: this.tickCount,
1084
+ seq: this.seq,
1085
+ gameTime: this.gameTime(),
1086
+ frozen: this.clock.isFrozenBy("emergency-stop"),
1087
+ pausedBy: this.pausedBy
1088
+ };
1089
+ }
908
1090
  handleConnection(ws, url) {
909
1091
  const playerId = url.searchParams.get("seatId");
910
1092
  if (!playerId) {
@@ -936,14 +1118,10 @@ var GameRoom = class _GameRoom {
936
1118
  type: "__room_init",
937
1119
  myId: playerId
938
1120
  });
1121
+ this.lastEmptyAtWall = null;
1122
+ this.unfreeze("no-players");
939
1123
  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
- });
1124
+ this.sendTo(ws, this.stateMessage());
947
1125
  }
948
1126
  this.maybeStartGame();
949
1127
  this.ensureTickLoop();
@@ -967,6 +1145,31 @@ var GameRoom = class _GameRoom {
967
1145
  }
968
1146
  const msgType = parsed.type;
969
1147
  console.log(`[GameRoom] \u2B05 recv from=${senderId} type=${msgType}`);
1148
+ if (msgType === "__pause") {
1149
+ if (!this.gameState) return;
1150
+ if (!this.clock.isFrozenBy("emergency-stop")) {
1151
+ this.pausedBy = senderId;
1152
+ this.freeze("emergency-stop");
1153
+ console.log(`[GameRoom] \u23F8 \u7DCA\u6025\u505C\u6B62 by=${senderId} gameTime=${this.gameTime()}`);
1154
+ }
1155
+ this.broadcastPauseState();
1156
+ return;
1157
+ }
1158
+ if (msgType === "__resume") {
1159
+ if (this.clock.isFrozenBy("emergency-stop")) {
1160
+ this.unfreeze("emergency-stop");
1161
+ this.pausedBy = null;
1162
+ console.log(`[GameRoom] \u25B6\uFE0F \u7DCA\u6025\u505C\u6B62\u3092\u89E3\u9664 by=${senderId} gameTime=${this.gameTime()}`);
1163
+ }
1164
+ this.broadcastPauseState();
1165
+ return;
1166
+ }
1167
+ if (this.frozen && (msgType === "__action" || msgType === "__input")) {
1168
+ if (msgType === "__action") {
1169
+ this.sendTo(ws, { type: "__action_error", error: "Paused", seq: parsed.seq });
1170
+ }
1171
+ return;
1172
+ }
970
1173
  if (msgType === "__action") {
971
1174
  if (!this.gameState) {
972
1175
  this.sendTo(ws, {
@@ -992,13 +1195,7 @@ var GameRoom = class _GameRoom {
992
1195
  }
993
1196
  if (msgType === "__request_state") {
994
1197
  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
- });
1198
+ this.sendTo(ws, this.stateMessage());
1002
1199
  }
1003
1200
  return;
1004
1201
  }
@@ -1022,15 +1219,19 @@ var GameRoom = class _GameRoom {
1022
1219
  const emit = (name, data) => events.push({ name, data: data ?? {} });
1023
1220
  const serverEmit = emit;
1024
1221
  const snapshot = structuredClone(this.gameState);
1025
- const now = Date.now();
1222
+ const time = this.gameTime();
1026
1223
  try {
1027
1224
  if (plain && !legacyServerOnly) {
1028
- plain({
1029
- state: this.gameState,
1030
- payload: payload ?? {},
1031
- playerId: senderId,
1032
- ctx: { now, emit }
1033
- });
1225
+ const state = this.gameState;
1226
+ withGameClock(
1227
+ time,
1228
+ () => plain({
1229
+ state,
1230
+ payload: payload ?? {},
1231
+ playerId: senderId,
1232
+ ctx: { ...this.timeCtx(time), emit }
1233
+ })
1234
+ );
1034
1235
  }
1035
1236
  if (serverHandler) {
1036
1237
  await serverHandler({
@@ -1040,7 +1241,7 @@ var GameRoom = class _GameRoom {
1040
1241
  ctx: {
1041
1242
  tick: this.tickCount,
1042
1243
  random: this.random ?? new SeededRandomImpl(this.seed),
1043
- now,
1244
+ ...this.timeCtx(time),
1044
1245
  emit: serverEmit
1045
1246
  }
1046
1247
  });
@@ -1057,28 +1258,50 @@ var GameRoom = class _GameRoom {
1057
1258
  wakeupTimer = null;
1058
1259
  wakeupAt = null;
1059
1260
  /**
1060
- * 宣言された締切のうち最も早い時刻。締切が無ければ null。
1261
+ * 宣言された締切のうち最も早いゲーム内時刻。締切が無ければ null。
1061
1262
  * state から毎回導出するので保存しない。
1062
1263
  */
1063
- nextDeadline() {
1264
+ earliestDeadline() {
1064
1265
  if (!this.gameState || !this.logic.deadlines) return null;
1065
1266
  let earliest = null;
1066
1267
  for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
1067
- let at;
1268
+ let raw;
1068
1269
  try {
1069
- at = deadline.at({ state: this.gameState });
1270
+ raw = deadline.at({ state: this.gameState });
1070
1271
  } catch (err) {
1071
1272
  console.error(`[Deadline] \u274C ${key}.at() \u3067\u4F8B\u5916`, err);
1072
1273
  continue;
1073
1274
  }
1074
- if (typeof at !== "number" || !Number.isFinite(at)) continue;
1275
+ const at = resolveDeadline(
1276
+ raw,
1277
+ key,
1278
+ this.warnedDeadlines,
1279
+ (m) => console.error(`[Deadline] \u274C ${m}`)
1280
+ );
1281
+ if (at === null) continue;
1075
1282
  if (earliest === null || at < earliest) earliest = at;
1076
1283
  }
1077
1284
  return earliest;
1078
1285
  }
1286
+ /**
1287
+ * 次に起きるべき実時刻。締切と無人猶予の早い方。
1288
+ *
1289
+ * 停止中はシナリオの締切を予約しない (facet と同じ)。
1290
+ */
1291
+ nextWakeupWall() {
1292
+ const candidates = [];
1293
+ if (!this.frozen) {
1294
+ const earliest = this.earliestDeadline();
1295
+ if (earliest !== null) candidates.push(this.clock.toWall(earliest));
1296
+ }
1297
+ if (this.lastEmptyAtWall !== null && !this.clock.isFrozenBy("no-players")) {
1298
+ candidates.push(this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS);
1299
+ }
1300
+ return candidates.length === 0 ? null : Math.min(...candidates);
1301
+ }
1079
1302
  /** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
1080
1303
  syncWakeup() {
1081
- const next = this.nextDeadline();
1304
+ const next = this.nextWakeupWall();
1082
1305
  if (next === this.wakeupAt) return;
1083
1306
  if (this.wakeupTimer) clearTimeout(this.wakeupTimer);
1084
1307
  this.wakeupTimer = null;
@@ -1087,6 +1310,14 @@ var GameRoom = class _GameRoom {
1087
1310
  this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
1088
1311
  this.wakeupTimer.unref?.();
1089
1312
  }
1313
+ /** 猶予が満了していれば無人凍結に入る。 */
1314
+ freezeIfEmptyGraceExpired() {
1315
+ if (this.lastEmptyAtWall === null) return;
1316
+ if (this.clock.isFrozenBy("no-players")) return;
1317
+ if (Date.now() < this.lastEmptyAtWall + NO_PLAYERS_GRACE_MS) return;
1318
+ console.log(`[GameRoom] \u{1F9CA} \u7121\u4EBA\u7336\u4E88 ${NO_PLAYERS_GRACE_MS}ms \u6E80\u4E86 \u2192 \u6642\u8A08\u3092\u505C\u6B62`);
1319
+ this.freeze("no-players");
1320
+ }
1090
1321
  /**
1091
1322
  * 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
1092
1323
  *
@@ -1096,8 +1327,12 @@ var GameRoom = class _GameRoom {
1096
1327
  fireDue() {
1097
1328
  this.wakeupTimer = null;
1098
1329
  this.wakeupAt = null;
1099
- if (!this.gameState || !this.logic.deadlines) return;
1100
- const now = Date.now();
1330
+ this.freezeIfEmptyGraceExpired();
1331
+ if (!this.gameState || !this.logic.deadlines || this.frozen) {
1332
+ this.syncWakeup();
1333
+ return;
1334
+ }
1335
+ const time = this.gameTime();
1101
1336
  const events = [];
1102
1337
  const firedKeys = [];
1103
1338
  for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
@@ -1106,17 +1341,25 @@ var GameRoom = class _GameRoom {
1106
1341
  const pending = [];
1107
1342
  let snapshot = null;
1108
1343
  try {
1109
- const at = deadline.at({ state });
1110
- if (typeof at !== "number" || !Number.isFinite(at) || at > now) continue;
1344
+ const at = resolveDeadline(
1345
+ deadline.at({ state }),
1346
+ key,
1347
+ this.warnedDeadlines,
1348
+ (m) => console.error(`[Deadline] \u274C ${m}`)
1349
+ );
1350
+ if (at === null || at > time) continue;
1111
1351
  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
- });
1352
+ withGameClock(
1353
+ time,
1354
+ () => deadline.handler({
1355
+ state,
1356
+ ctx: {
1357
+ ...this.timeCtx(time),
1358
+ random: this.random ?? new SeededRandomImpl(this.seed),
1359
+ emit: (name, data) => pending.push({ name, data: data ?? {} })
1360
+ }
1361
+ })
1362
+ );
1120
1363
  events.push(...pending);
1121
1364
  firedKeys.push(key);
1122
1365
  } catch (err) {
@@ -1138,9 +1381,12 @@ var GameRoom = class _GameRoom {
1138
1381
  this.sockets.delete(ws);
1139
1382
  this.attachments.delete(ws);
1140
1383
  delete this.playerInputs[attachment.playerId];
1141
- if (this.sockets.size === 0 && this.tickTimer) {
1142
- clearInterval(this.tickTimer);
1143
- this.tickTimer = null;
1384
+ if (this.sockets.size === 0) {
1385
+ this.stopTickLoop();
1386
+ if (this.gameState !== null && this.lastEmptyAtWall === null) {
1387
+ this.lastEmptyAtWall = Date.now();
1388
+ this.syncWakeup();
1389
+ }
1144
1390
  }
1145
1391
  }
1146
1392
  // ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
@@ -1190,7 +1436,13 @@ var GameRoom = class _GameRoom {
1190
1436
  this.seed = opts.seed;
1191
1437
  }
1192
1438
  this.random = new SeededRandomImpl(this.seed);
1193
- this.gameState = this.logic.setup(this.setupArgs(this.random));
1439
+ this.clock.restart();
1440
+ this.pausedBy = null;
1441
+ this.lastEmptyAtWall = null;
1442
+ const setupTime = this.gameTime();
1443
+ const args = this.setupArgs(this.random);
1444
+ this.gameState = withGameClock(setupTime, () => this.logic.setup(args));
1445
+ this.stateInitialized = true;
1194
1446
  this.tickCount = 0;
1195
1447
  this.tickPaused = false;
1196
1448
  this.seq = 0;
@@ -1199,8 +1451,10 @@ var GameRoom = class _GameRoom {
1199
1451
  type: "__game_start",
1200
1452
  state: this.gameState,
1201
1453
  seed: this.seed,
1202
- seq: 0
1454
+ seq: 0,
1455
+ gameTime: this.gameTime()
1203
1456
  });
1457
+ this.syncWakeup();
1204
1458
  this.notifySnapshotSubscribers();
1205
1459
  this.ensureTickLoop();
1206
1460
  },
@@ -6,6 +6,214 @@ 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-time.ts
10
+ var toMs = (t) => t;
11
+ var plus = (t, d) => toMs(t) + d;
12
+ var sub = (t, d) => toMs(t) - d;
13
+ var minus = (a, b) => toMs(a) - toMs(b);
14
+ var asGameTime = (ms) => ms;
15
+
16
+ // ../engine-core/src/game-clock.ts
17
+ var NO_PLAYERS_GRACE_MS = 5 * 6e4;
18
+ var GameClock = class {
19
+ startedAtWall = 0;
20
+ pausedTotalMs = 0;
21
+ pausedAtWall = null;
22
+ frozenBy = /* @__PURE__ */ new Set();
23
+ /** 時計を 0 から始め直す。`setup()` を呼ぶ直前に通す。 */
24
+ restart() {
25
+ this.startedAtWall = Date.now();
26
+ this.pausedTotalMs = 0;
27
+ this.pausedAtWall = null;
28
+ this.frozenBy.clear();
29
+ }
30
+ get frozen() {
31
+ return this.frozenBy.size > 0;
32
+ }
33
+ isFrozenBy(reason) {
34
+ return this.frozenBy.has(reason);
35
+ }
36
+ /**
37
+ * 現在のゲーム内時刻。停止中は `pausedAtWall` で凍るので同じ値を返し続ける。
38
+ *
39
+ * IMPORTANT: [withGameClock] の中で呼んではいけない。実装が `Date.now()` を読むので、
40
+ * 差し替え済みの時刻を実時刻として引き算し、大きく負の値になる。
41
+ * ハンドラへ渡す時刻は必ず外で 1 回取ってから渡すこと。
42
+ */
43
+ now() {
44
+ return asGameTime((this.pausedAtWall ?? Date.now()) - this.startedAtWall - this.pausedTotalMs);
45
+ }
46
+ /** ゲーム内時刻を、alarm / setTimeout が使う実時刻へ直す。 */
47
+ toWall(at) {
48
+ return toMs(at) + this.startedAtWall + this.pausedTotalMs;
49
+ }
50
+ /** 実際に理由を足したら true。呼び出し側が永続化や tick 停止の要否に使う。 */
51
+ freeze(reason) {
52
+ if (this.frozenBy.has(reason)) return false;
53
+ const wasFrozen = this.frozen;
54
+ this.frozenBy.add(reason);
55
+ if (!wasFrozen) this.pausedAtWall = Date.now();
56
+ return true;
57
+ }
58
+ /** 実際に理由を外したら true。 */
59
+ unfreeze(reason) {
60
+ if (!this.frozenBy.delete(reason)) return false;
61
+ if (!this.frozen && this.pausedAtWall !== null) {
62
+ this.pausedTotalMs += Date.now() - this.pausedAtWall;
63
+ this.pausedAtWall = null;
64
+ }
65
+ return true;
66
+ }
67
+ snapshot() {
68
+ return {
69
+ startedAtWall: this.startedAtWall,
70
+ pausedTotalMs: this.pausedTotalMs,
71
+ pausedAtWall: this.pausedAtWall,
72
+ frozenBy: [...this.frozenBy]
73
+ };
74
+ }
75
+ /**
76
+ * 永続化した内容から復元する。欠損は「未停止」に倒す。
77
+ *
78
+ * 読めなかったせいで世界が止まったままになる方が、動き出すより悪い。
79
+ */
80
+ restore(saved) {
81
+ this.startedAtWall = saved.startedAtWall ?? 0;
82
+ this.pausedTotalMs = saved.pausedTotalMs ?? 0;
83
+ this.pausedAtWall = saved.pausedAtWall ?? null;
84
+ this.frozenBy.clear();
85
+ for (const reason of saved.frozenBy ?? []) this.frozenBy.add(reason);
86
+ if (this.frozen && this.pausedAtWall === null) this.frozenBy.clear();
87
+ }
88
+ };
89
+ function withGameClock(time, fn) {
90
+ const realNow = Date.now;
91
+ const RealDate = Date;
92
+ const ms = toMs(time);
93
+ Date.now = () => ms;
94
+ globalThis.Date = new Proxy(RealDate, {
95
+ construct: (target, args) => Reflect.construct(target, args.length === 0 ? [ms] : args)
96
+ });
97
+ try {
98
+ return fn();
99
+ } finally {
100
+ globalThis.Date = RealDate;
101
+ Date.now = realNow;
102
+ }
103
+ }
104
+ function resolveDeadline(at, key, warned, report) {
105
+ if (at === null || at === void 0) return null;
106
+ if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
107
+ if (!warned.has(key)) {
108
+ warned.add(key);
109
+ report(
110
+ `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`
111
+ );
112
+ }
113
+ return null;
114
+ }
115
+
116
+ // ../engine-core/src/json-patch.ts
117
+ function escapePointer(key) {
118
+ return key.replace(/~/g, "~0").replace(/\//g, "~1");
119
+ }
120
+ function unescapePointer(token) {
121
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
122
+ }
123
+ function compare(oldObj, newObj, basePath = "") {
124
+ if (oldObj === newObj) return [];
125
+ if (oldObj === null || newObj === null || typeof oldObj !== "object" || typeof newObj !== "object") {
126
+ return [{ op: "replace", path: basePath || "/", value: newObj }];
127
+ }
128
+ if (Array.isArray(oldObj) || Array.isArray(newObj)) {
129
+ if (JSON.stringify(oldObj) === JSON.stringify(newObj)) return [];
130
+ return [{ op: "replace", path: basePath || "/", value: newObj }];
131
+ }
132
+ const ops = [];
133
+ const oldKeys = Object.keys(oldObj);
134
+ const newKeys = Object.keys(newObj);
135
+ for (const key of oldKeys) {
136
+ if (!(key in newObj)) {
137
+ ops.push({ op: "remove", path: `${basePath}/${escapePointer(key)}` });
138
+ }
139
+ }
140
+ for (const key of newKeys) {
141
+ const childPath = `${basePath}/${escapePointer(key)}`;
142
+ if (!(key in oldObj)) {
143
+ ops.push({ op: "add", path: childPath, value: newObj[key] });
144
+ } else {
145
+ const childOps = compare(oldObj[key], newObj[key], childPath);
146
+ ops.push(...childOps);
147
+ }
148
+ }
149
+ return ops;
150
+ }
151
+ function applyPatch(doc, ops) {
152
+ for (const op of ops) {
153
+ const tokens = op.path.split("/").slice(1).map(unescapePointer);
154
+ if (tokens.length === 0) return false;
155
+ if (op.op === "replace" || op.op === "add") {
156
+ let target = doc;
157
+ for (let i = 0; i < tokens.length - 1; i++) {
158
+ target = target?.[tokens[i]];
159
+ if (target === void 0 || target === null) return false;
160
+ }
161
+ const lastKey = tokens[tokens.length - 1];
162
+ target[lastKey] = op.value;
163
+ } else if (op.op === "remove") {
164
+ let target = doc;
165
+ for (let i = 0; i < tokens.length - 1; i++) {
166
+ target = target?.[tokens[i]];
167
+ if (target === void 0 || target === null) return false;
168
+ }
169
+ const lastKey = tokens[tokens.length - 1];
170
+ if (Array.isArray(target)) {
171
+ target.splice(Number(lastKey), 1);
172
+ } else {
173
+ delete target[lastKey];
174
+ }
175
+ }
176
+ }
177
+ return true;
178
+ }
179
+
180
+ // ../engine-core/src/random.ts
181
+ var SeededRandomImpl = class _SeededRandomImpl {
182
+ _state;
183
+ constructor(seed) {
184
+ this._state = seed | 0;
185
+ }
186
+ get state() {
187
+ return this._state;
188
+ }
189
+ static fromState(state) {
190
+ const r = new _SeededRandomImpl(0);
191
+ r._state = state;
192
+ return r;
193
+ }
194
+ float() {
195
+ this._state |= 0;
196
+ this._state = this._state + 1831565813 | 0;
197
+ let t = Math.imul(this._state ^ this._state >>> 15, 1 | this._state);
198
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
199
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
200
+ }
201
+ int(max) {
202
+ return Math.floor(this.float() * max);
203
+ }
204
+ pick(array) {
205
+ return array[this.int(array.length)];
206
+ }
207
+ shuffle(array) {
208
+ const a = [...array];
209
+ for (let i = a.length - 1; i > 0; i--) {
210
+ const j = this.int(i + 1);
211
+ [a[i], a[j]] = [a[j], a[i]];
212
+ }
213
+ return a;
214
+ }
215
+ };
216
+
9
217
  // ../engine-core/src/server-only.ts
10
218
  function serverOnly(handler) {
11
219
  return Object.assign(handler, { __serverOnly: true });
@@ -13,9 +221,45 @@ function serverOnly(handler) {
13
221
  function isServerOnlyAction(handler) {
14
222
  return typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
15
223
  }
224
+
225
+ // ../engine-core/src/roster.ts
226
+ function parseRoster(rosterParam) {
227
+ if (!rosterParam) return null;
228
+ let raw;
229
+ try {
230
+ raw = JSON.parse(rosterParam);
231
+ } catch {
232
+ return null;
233
+ }
234
+ if (!Array.isArray(raw)) return null;
235
+ return raw.filter((p) => (p.kind ?? "player") === "player").map((p) => ({
236
+ id: p.id,
237
+ nickname: p.name ?? "Guest",
238
+ iconUrl: p.iconUrl ?? "",
239
+ characterId: p.characterId
240
+ }));
241
+ }
242
+
243
+ // ../engine-core/src/versions.ts
244
+ var BRIDGE_VERSION = 2;
245
+ var WIRE_VERSION = 2;
16
246
  export {
247
+ BRIDGE_VERSION,
17
248
  DEFAULT_ICON_URLS,
249
+ GameClock,
250
+ NO_PLAYERS_GRACE_MS,
18
251
  SERVER_TIME,
252
+ SeededRandomImpl,
253
+ WIRE_VERSION,
254
+ applyPatch,
255
+ asGameTime,
256
+ compare,
19
257
  isServerOnlyAction,
20
- serverOnly
258
+ minus,
259
+ parseRoster,
260
+ plus,
261
+ resolveDeadline,
262
+ serverOnly,
263
+ sub,
264
+ withGameClock
21
265
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-cli",
3
- "version": "0.5.10",
3
+ "version": "0.6.1",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {