@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 +321 -67
- package/dist/dev-server/sdk-server-shim.js +245 -1
- package/package.json +1 -1
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 =
|
|
677
|
-
var WIRE_VERSION =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
|
1047
|
+
const time = this.gameTime();
|
|
1048
|
+
const random = this.random;
|
|
1049
|
+
const state = this.gameState;
|
|
887
1050
|
try {
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
|
1222
|
+
const time = this.gameTime();
|
|
1026
1223
|
try {
|
|
1027
1224
|
if (plain && !legacyServerOnly) {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1261
|
+
* 宣言された締切のうち最も早いゲーム内時刻。締切が無ければ null。
|
|
1061
1262
|
* state から毎回導出するので保存しない。
|
|
1062
1263
|
*/
|
|
1063
|
-
|
|
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
|
|
1268
|
+
let raw;
|
|
1068
1269
|
try {
|
|
1069
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1100
|
-
|
|
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 =
|
|
1110
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
|
1142
|
-
|
|
1143
|
-
this.
|
|
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.
|
|
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
|
-
|
|
258
|
+
minus,
|
|
259
|
+
parseRoster,
|
|
260
|
+
plus,
|
|
261
|
+
resolveDeadline,
|
|
262
|
+
serverOnly,
|
|
263
|
+
sub,
|
|
264
|
+
withGameClock
|
|
21
265
|
};
|