@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 +320 -68
- package/dist/dev-server/sdk-server-shim.js +3 -0
- package/package.json +1 -1
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 =
|
|
677
|
-
var WIRE_VERSION =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
996
|
+
this.seed = Math.floor(Math.random() * 4294967295);
|
|
851
997
|
this.random = new SeededRandomImpl(this.seed);
|
|
852
|
-
this.
|
|
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
|
|
1045
|
+
const time = this.gameTime();
|
|
1046
|
+
const random = this.random;
|
|
1047
|
+
const state = this.gameState;
|
|
887
1048
|
try {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
1220
|
+
const time = this.gameTime();
|
|
1026
1221
|
try {
|
|
1027
1222
|
if (plain && !legacyServerOnly) {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1259
|
+
* 宣言された締切のうち最も早いゲーム内時刻。締切が無ければ null。
|
|
1061
1260
|
* state から毎回導出するので保存しない。
|
|
1062
1261
|
*/
|
|
1063
|
-
|
|
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
|
|
1266
|
+
let raw;
|
|
1068
1267
|
try {
|
|
1069
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1100
|
-
|
|
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 =
|
|
1110
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
|
1142
|
-
|
|
1143
|
-
this.
|
|
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.
|
|
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 });
|