@uzuhq/code-cli 0.5.0 → 0.5.2
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.
|
@@ -14,8 +14,6 @@
|
|
|
14
14
|
import { randomUUID } from 'crypto';
|
|
15
15
|
import { compare, applyJsonMergePatch, applyJsonPatch } from './json-patch.js';
|
|
16
16
|
import { SeededRandomImpl } from './random.js';
|
|
17
|
-
import { SCHEDULED_ACTOR } from './game-types.js';
|
|
18
|
-
import { ScheduleDraft, loadQueue, popDue, saveQueue, } from './scheduler.js';
|
|
19
17
|
/**
|
|
20
18
|
* serverOnly() で wrap された handler かを判定する (SDK の isServerOnlyAction と同じ brand)。
|
|
21
19
|
* dev-server は SDK を import できないため実装を持つ。
|
|
@@ -162,7 +160,10 @@ export class GameRoom {
|
|
|
162
160
|
return;
|
|
163
161
|
this.seed = Date.now() & 0xffffffff;
|
|
164
162
|
this.random = new SeededRandomImpl(this.seed);
|
|
165
|
-
this.gameState = this.logic.setup(
|
|
163
|
+
this.gameState = this.logic.setup({
|
|
164
|
+
seats: this.seats,
|
|
165
|
+
ctx: { random: this.random, now: Date.now() },
|
|
166
|
+
});
|
|
166
167
|
this.stateInitialized = true;
|
|
167
168
|
this.tickCount = 0;
|
|
168
169
|
this.seq = 0;
|
|
@@ -205,9 +206,7 @@ export class GameRoom {
|
|
|
205
206
|
const events = [];
|
|
206
207
|
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
207
208
|
// ctx.now と予約の基準時刻は同じ値にする。別々に Date.now() を呼ぶと
|
|
208
|
-
// 「schedule の戻り値 = ctx.now + after * 1000」という約束が数 ms 崩れる。
|
|
209
209
|
const tickNow = Date.now();
|
|
210
|
-
const tickDraft = new ScheduleDraft(tickNow, (a) => this.hasAction(a));
|
|
211
210
|
try {
|
|
212
211
|
this.logic.update({
|
|
213
212
|
state: this.gameState,
|
|
@@ -215,8 +214,6 @@ export class GameRoom {
|
|
|
215
214
|
random: this.random,
|
|
216
215
|
tick: this.tickCount,
|
|
217
216
|
now: tickNow,
|
|
218
|
-
schedule: (o) => tickDraft.schedule(o),
|
|
219
|
-
unschedule: (k) => tickDraft.unschedule(k),
|
|
220
217
|
emit,
|
|
221
218
|
playerInputs: this.playerInputs,
|
|
222
219
|
},
|
|
@@ -228,8 +225,7 @@ export class GameRoom {
|
|
|
228
225
|
return;
|
|
229
226
|
}
|
|
230
227
|
this.tickCount++;
|
|
231
|
-
|
|
232
|
-
void this.flushSchedule(tickDraft);
|
|
228
|
+
this.syncWakeup();
|
|
233
229
|
this.broadcastStateDelta(events, { tick: this.tickCount });
|
|
234
230
|
}
|
|
235
231
|
// ─── Connection ────────────────────────────────────────
|
|
@@ -380,11 +376,6 @@ export class GameRoom {
|
|
|
380
376
|
// 値が変わると、片方だけを見た state が一瞬矛盾する。
|
|
381
377
|
const now = Date.now();
|
|
382
378
|
// handler 内では配列を触るだけにして、反映は dispatch 後にまとめて行う。
|
|
383
|
-
const draft = new ScheduleDraft(now, (a) => this.hasAction(a));
|
|
384
|
-
const scheduleCtx = {
|
|
385
|
-
schedule: (o) => draft.schedule(o),
|
|
386
|
-
unschedule: (k) => draft.unschedule(k),
|
|
387
|
-
};
|
|
388
379
|
try {
|
|
389
380
|
// 決定的な部分を先に走らせ、serverActions がその結果を見られるようにする。
|
|
390
381
|
if (plain && !legacyServerOnly) {
|
|
@@ -392,7 +383,7 @@ export class GameRoom {
|
|
|
392
383
|
state: this.gameState,
|
|
393
384
|
payload: payload ?? {},
|
|
394
385
|
playerId: senderId,
|
|
395
|
-
ctx: { now, emit
|
|
386
|
+
ctx: { now, emit },
|
|
396
387
|
});
|
|
397
388
|
}
|
|
398
389
|
if (serverHandler) {
|
|
@@ -405,7 +396,6 @@ export class GameRoom {
|
|
|
405
396
|
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
406
397
|
now,
|
|
407
398
|
emit: serverEmit,
|
|
408
|
-
...scheduleCtx,
|
|
409
399
|
},
|
|
410
400
|
});
|
|
411
401
|
}
|
|
@@ -414,78 +404,103 @@ export class GameRoom {
|
|
|
414
404
|
this.gameState = snapshot;
|
|
415
405
|
throw err;
|
|
416
406
|
}
|
|
417
|
-
|
|
418
|
-
await this.flushSchedule(draft);
|
|
407
|
+
this.syncWakeup();
|
|
419
408
|
this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
|
|
420
409
|
}
|
|
421
|
-
// ───
|
|
422
|
-
/** dev
|
|
423
|
-
scheduled = [];
|
|
410
|
+
// ─── Deadlines (本番の Durable Object alarm 相当) ──────────
|
|
411
|
+
/** dev は setTimeout。本番は storage の alarm。意味論は同じ。 */
|
|
424
412
|
wakeupTimer = null;
|
|
425
|
-
|
|
426
|
-
return {
|
|
427
|
-
get: async () => this.scheduled,
|
|
428
|
-
put: async (entries) => {
|
|
429
|
-
this.scheduled = entries;
|
|
430
|
-
},
|
|
431
|
-
setWakeup: async (at) => {
|
|
432
|
-
if (this.wakeupTimer)
|
|
433
|
-
clearTimeout(this.wakeupTimer);
|
|
434
|
-
this.wakeupTimer = null;
|
|
435
|
-
if (at == null)
|
|
436
|
-
return;
|
|
437
|
-
this.wakeupTimer = setTimeout(() => void this.fireDue(), Math.max(0, at - Date.now()));
|
|
438
|
-
},
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
hasAction(name) {
|
|
442
|
-
return Boolean(this.logic.actions[name] || this.logic.serverActions?.[name]);
|
|
443
|
-
}
|
|
413
|
+
wakeupAt = null;
|
|
444
414
|
/**
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
* tick / action / 発火処理が同時に走ると、後着が先着を上書きして予約が消える。
|
|
448
|
-
* 呼び出し順に 1 本の鎖へ並べる (play-server 版と同じ)。
|
|
415
|
+
* 宣言された締切のうち最も早い時刻。締切が無ければ null。
|
|
416
|
+
* state から毎回導出するので保存しない。
|
|
449
417
|
*/
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
418
|
+
nextDeadline() {
|
|
419
|
+
if (!this.gameState || !this.logic.deadlines)
|
|
420
|
+
return null;
|
|
421
|
+
let earliest = null;
|
|
422
|
+
for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
|
|
423
|
+
let at;
|
|
424
|
+
try {
|
|
425
|
+
at = deadline.at({ state: this.gameState });
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (typeof at !== 'number' || !Number.isFinite(at))
|
|
432
|
+
continue;
|
|
433
|
+
if (earliest === null || at < earliest)
|
|
434
|
+
earliest = at;
|
|
435
|
+
}
|
|
436
|
+
return earliest;
|
|
461
437
|
}
|
|
462
|
-
|
|
463
|
-
|
|
438
|
+
/** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
|
|
439
|
+
syncWakeup() {
|
|
440
|
+
const next = this.nextDeadline();
|
|
441
|
+
if (next === this.wakeupAt)
|
|
442
|
+
return;
|
|
443
|
+
if (this.wakeupTimer)
|
|
444
|
+
clearTimeout(this.wakeupTimer);
|
|
445
|
+
this.wakeupTimer = null;
|
|
446
|
+
this.wakeupAt = next;
|
|
447
|
+
if (next === null)
|
|
448
|
+
return;
|
|
449
|
+
this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
|
|
450
|
+
// dev の setTimeout は process を掴み続けるので、他に仕事が無ければ抜けられるようにする。
|
|
451
|
+
this.wakeupTimer.unref?.();
|
|
464
452
|
}
|
|
465
453
|
/**
|
|
466
|
-
*
|
|
454
|
+
* 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
|
|
467
455
|
*
|
|
468
|
-
*
|
|
456
|
+
* handler の直前に at を評価し直す (先に走った handler が別の締切を消しうる)。
|
|
457
|
+
* 失敗は握って捨てる。ログには必ず残す。
|
|
469
458
|
*/
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
459
|
+
fireDue() {
|
|
460
|
+
this.wakeupTimer = null;
|
|
461
|
+
this.wakeupAt = null;
|
|
462
|
+
if (!this.gameState || !this.logic.deadlines)
|
|
463
|
+
return;
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
const events = [];
|
|
466
|
+
const firedKeys = [];
|
|
467
|
+
for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
|
|
468
|
+
// 巻き戻しで this.gameState が差し替わるので、毎周読み直す。
|
|
469
|
+
const state = this.gameState;
|
|
470
|
+
if (!state)
|
|
471
|
+
break;
|
|
472
|
+
// handler が emit してから throw したときに捨てられるよう、締切ごとに溜める。
|
|
473
|
+
// state を戻したのに音や演出だけ流れると、起きていない出来事が見えてしまう。
|
|
474
|
+
const pending = [];
|
|
475
|
+
let snapshot = null;
|
|
482
476
|
try {
|
|
483
|
-
|
|
477
|
+
const at = deadline.at({ state });
|
|
478
|
+
if (typeof at !== 'number' || !Number.isFinite(at) || at > now)
|
|
479
|
+
continue;
|
|
480
|
+
// 期限が来たものだけ複製する。毎周撮ると締切の数だけ state の複製が走る。
|
|
481
|
+
snapshot = structuredClone(state);
|
|
482
|
+
deadline.handler({
|
|
483
|
+
state,
|
|
484
|
+
ctx: {
|
|
485
|
+
now,
|
|
486
|
+
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
487
|
+
emit: (name, data) => pending.push({ name, data: data ?? {} }),
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
events.push(...pending);
|
|
491
|
+
firedKeys.push(key);
|
|
484
492
|
}
|
|
485
493
|
catch (err) {
|
|
486
|
-
|
|
494
|
+
if (snapshot !== null)
|
|
495
|
+
this.gameState = snapshot;
|
|
496
|
+
console.error(`[Deadline] ❌ ${key} で例外`, err);
|
|
487
497
|
}
|
|
488
498
|
}
|
|
499
|
+
this.syncWakeup();
|
|
500
|
+
if (firedKeys.length === 0)
|
|
501
|
+
return;
|
|
502
|
+
console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(', ')}`);
|
|
503
|
+
this.broadcastStateDelta(events, {});
|
|
489
504
|
}
|
|
490
505
|
handleClose(ws) {
|
|
491
506
|
const attachment = this.attachments.get(ws);
|
|
@@ -554,7 +569,10 @@ export class GameRoom {
|
|
|
554
569
|
this.seed = opts.seed;
|
|
555
570
|
}
|
|
556
571
|
this.random = new SeededRandomImpl(this.seed);
|
|
557
|
-
this.gameState = this.logic.setup(
|
|
572
|
+
this.gameState = this.logic.setup({
|
|
573
|
+
seats: this.seats,
|
|
574
|
+
ctx: { random: this.random, now: Date.now() },
|
|
575
|
+
});
|
|
558
576
|
this.tickCount = 0;
|
|
559
577
|
// reset は新規ゲーム = 実行可能状態を意味するので pause も解除する。
|
|
560
578
|
this.tickPaused = false;
|
|
@@ -8,10 +8,4 @@
|
|
|
8
8
|
* SDK を直接 import できず、game-worker-template と同じく copy を持つ。
|
|
9
9
|
* 3 か所いずれかを変更したら他 2 か所も同期させること。
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
* 予約から発火した action に渡る playerId。
|
|
13
|
-
*
|
|
14
|
-
* 送信者がいないので、人間の操作と区別するための定数。
|
|
15
|
-
* `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
|
|
16
|
-
*/
|
|
17
|
-
export const SCHEDULED_ACTOR = '__scheduled';
|
|
11
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @docs
|
|
3
|
-
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
4
|
-
*
|
|
5
|
-
* サーバー権威のタイマー。
|
|
6
|
-
*
|
|
7
|
-
* これは dev-server (Node) 用のコピー。本番の Durable Object 版
|
|
8
|
-
* (play-server/src/game-worker-template/scheduler.ts) と**同一内容に保つこと**。
|
|
9
|
-
* 意味論がずれると「dev では動くが本番で止まる」という一番タチの悪い差になる。
|
|
10
|
-
*
|
|
11
|
-
* dev では alarm の代わりに setTimeout、storage の代わりにメモリを使う (SchedulerStore
|
|
12
|
-
* の実装差だけで吸収する)。
|
|
13
|
-
*/
|
|
14
|
-
const STORAGE_KEY = '__scheduled';
|
|
15
|
-
/**
|
|
16
|
-
* 同時に持てる予約の上限。
|
|
17
|
-
*
|
|
18
|
-
* key で置き換わるので通常は増えないが、キーを動的に生成する (`narration:${i++}` 等)
|
|
19
|
-
* バグを踏むと際限なく積み上がる。その歯止め。
|
|
20
|
-
*
|
|
21
|
-
* NOTE: 値そのものに根拠は無い。マダミスの実データ (フェーズ数 + ナレーション行列で
|
|
22
|
-
* 数十件) から見て十分に余裕がある数字を置いているだけ。
|
|
23
|
-
*/
|
|
24
|
-
export const MAX_SCHEDULED = 256;
|
|
25
|
-
/**
|
|
26
|
-
* 予約できる先の上限 (ms)。
|
|
27
|
-
*
|
|
28
|
-
* 狙いは単位の取り違え。`after: 1200 * 1000` (ms のつもり) を書くと 13.9 日後になるが、
|
|
29
|
-
* 型では防げない。ゲームは長くても数時間なので、これを超える予約は事実上バグ。
|
|
30
|
-
*
|
|
31
|
-
* NOTE: 値そのものに根拠は無い。
|
|
32
|
-
*/
|
|
33
|
-
export const MAX_HORIZON_MS = 24 * 60 * 60 * 1000;
|
|
34
|
-
/**
|
|
35
|
-
* 1 dispatch ぶんの予約変更を溜めるバッファ。
|
|
36
|
-
*
|
|
37
|
-
* action handler の中では同期的に呼ばれるので、ここでは配列を触るだけにして、
|
|
38
|
-
* dispatch が終わってからまとめてストレージへ書き戻す (I/O を handler の外へ出す)。
|
|
39
|
-
*/
|
|
40
|
-
export class ScheduleDraft {
|
|
41
|
-
now;
|
|
42
|
-
knownActions;
|
|
43
|
-
added = [];
|
|
44
|
-
removed = new Set();
|
|
45
|
-
/** 予約 / 取り消しが 1 件でもあったか。無ければストレージに触らない。 */
|
|
46
|
-
dirty = false;
|
|
47
|
-
constructor(now, knownActions) {
|
|
48
|
-
this.now = now;
|
|
49
|
-
this.knownActions = knownActions;
|
|
50
|
-
}
|
|
51
|
-
schedule(options) {
|
|
52
|
-
const { key, action } = options;
|
|
53
|
-
if (options.at != null && options.after != null) {
|
|
54
|
-
throw new Error(`schedule: key=${key} — at と after は排他です`);
|
|
55
|
-
}
|
|
56
|
-
const at = options.at != null ? Number(options.at) : this.now + Number(options.after ?? 0) * 1000;
|
|
57
|
-
// IMPORTANT: 検査に引っかかったら握り潰さず throw する。
|
|
58
|
-
//
|
|
59
|
-
// 黙って捨てると、呼び出し側は予約できたつもりで戻り値を endsAt として state に入れ、
|
|
60
|
-
// タイマーが表示されたまま永久に発火しない = ゲームが静かに止まる。予約方式は
|
|
61
|
-
// 「何も起きないこと」に気づけないので、その場で失敗させるしかない。
|
|
62
|
-
// ここに来るのはバグのときだけなので、action 全体がロールバックされて構わない。
|
|
63
|
-
if (!key)
|
|
64
|
-
throw new Error('schedule: key は必須です');
|
|
65
|
-
if (!Number.isFinite(at))
|
|
66
|
-
throw new Error(`schedule: key=${key} の時刻が不正です`);
|
|
67
|
-
if (!this.knownActions(action)) {
|
|
68
|
-
throw new Error(`schedule: key=${key} の action "${action}" が logic に存在しません`);
|
|
69
|
-
}
|
|
70
|
-
if (at - this.now > MAX_HORIZON_MS) {
|
|
71
|
-
const days = ((at - this.now) / 86_400_000).toFixed(1);
|
|
72
|
-
throw new Error(`schedule: key=${key} の予約が上限 (24h) を超えています (${days} 日後)。` +
|
|
73
|
-
' after は「秒」です。ms を渡していませんか');
|
|
74
|
-
}
|
|
75
|
-
this.removed.add(key);
|
|
76
|
-
const idx = this.added.findIndex((e) => e.key === key);
|
|
77
|
-
const entry = { key, at, action, payload: options.payload ?? {} };
|
|
78
|
-
if (idx >= 0)
|
|
79
|
-
this.added[idx] = entry;
|
|
80
|
-
else
|
|
81
|
-
this.added.push(entry);
|
|
82
|
-
this.dirty = true;
|
|
83
|
-
return at;
|
|
84
|
-
}
|
|
85
|
-
unschedule(key) {
|
|
86
|
-
this.removed.add(key);
|
|
87
|
-
const idx = this.added.findIndex((e) => e.key === key);
|
|
88
|
-
if (idx >= 0)
|
|
89
|
-
this.added.splice(idx, 1);
|
|
90
|
-
this.dirty = true;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* 既存キューへ変更を適用する。key 単位で必ず 1 件に畳まれる。
|
|
94
|
-
*
|
|
95
|
-
* 上限超過は throw する。どれを捨てるかを SDK が決めると、フェーズタイマー (遠い) を
|
|
96
|
-
* 捨ててナレーション (近い) を残す、といった優先順位の判断を勝手にすることになる。
|
|
97
|
-
*/
|
|
98
|
-
apply(current) {
|
|
99
|
-
const next = current.filter((e) => !this.removed.has(e.key));
|
|
100
|
-
next.push(...this.added);
|
|
101
|
-
if (next.length > MAX_SCHEDULED) {
|
|
102
|
-
throw new Error(`schedule: 予約が上限 ${MAX_SCHEDULED} 件を超えました (${next.length} 件)。` +
|
|
103
|
-
' key を動的に生成していませんか');
|
|
104
|
-
}
|
|
105
|
-
next.sort((a, b) => a.at - b.at);
|
|
106
|
-
return next;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
/** ストレージのキューを読む。 */
|
|
110
|
-
export async function loadQueue(store) {
|
|
111
|
-
return (await store.get()) ?? [];
|
|
112
|
-
}
|
|
113
|
-
/** キューを書き戻し、最も早い 1 件へ起床時刻を張り替える。 */
|
|
114
|
-
export async function saveQueue(store, entries) {
|
|
115
|
-
await store.put(entries);
|
|
116
|
-
const next = entries.length > 0 ? entries[0] : null;
|
|
117
|
-
await store.setWakeup(next ? next.at : null);
|
|
118
|
-
// 予約方式は「静かに何も起きない」ので、状態がログに出ないと調査手段が無くなる。
|
|
119
|
-
if (next) {
|
|
120
|
-
const inSec = Math.round((next.at - Date.now()) / 1000);
|
|
121
|
-
console.log(`[Scheduler] 📅 ${entries.length} 件 / 次は ${next.key} (${next.action}) ${inSec}s 後`);
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
console.log('[Scheduler] 📭 予約なし');
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
/** 期限が来た予約を取り出し、残りを返す。 */
|
|
128
|
-
export function popDue(entries, now) {
|
|
129
|
-
const due = [];
|
|
130
|
-
const rest = [];
|
|
131
|
-
for (const e of entries)
|
|
132
|
-
(e.at <= now ? due : rest).push(e);
|
|
133
|
-
return { due, rest };
|
|
134
|
-
}
|
|
135
|
-
export const SCHEDULER_STORAGE_KEY = STORAGE_KEY;
|