@uzuhq/code-cli 0.5.1 → 0.5.3

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 できないため実装を持つ。
@@ -208,9 +206,7 @@ export class GameRoom {
208
206
  const events = [];
209
207
  const emit = (name, data) => events.push({ name, data: data ?? {} });
210
208
  // ctx.now と予約の基準時刻は同じ値にする。別々に Date.now() を呼ぶと
211
- // 「schedule の戻り値 = ctx.now + after * 1000」という約束が数 ms 崩れる。
212
209
  const tickNow = Date.now();
213
- const tickDraft = new ScheduleDraft(tickNow, (a) => this.hasAction(a));
214
210
  try {
215
211
  this.logic.update({
216
212
  state: this.gameState,
@@ -218,8 +214,6 @@ export class GameRoom {
218
214
  random: this.random,
219
215
  tick: this.tickCount,
220
216
  now: tickNow,
221
- schedule: (o) => tickDraft.schedule(o),
222
- unschedule: (k) => tickDraft.unschedule(k),
223
217
  emit,
224
218
  playerInputs: this.playerInputs,
225
219
  },
@@ -231,8 +225,7 @@ export class GameRoom {
231
225
  return;
232
226
  }
233
227
  this.tickCount++;
234
- if (tickDraft.dirty)
235
- void this.flushSchedule(tickDraft);
228
+ this.syncWakeup();
236
229
  this.broadcastStateDelta(events, { tick: this.tickCount });
237
230
  }
238
231
  // ─── Connection ────────────────────────────────────────
@@ -297,6 +290,17 @@ export class GameRoom {
297
290
  this.ensureTickLoop();
298
291
  }
299
292
  async handleMessage(ws, msg) {
293
+ // SDK は生文字列で送受信する (本番の setWebSocketAutoResponse と同じ wire 形式)。
294
+ // JSON.parse より前に返さないと heartbeat が落ちて 35 秒ごとに全席が再接続する。
295
+ if (msg === '__ping') {
296
+ try {
297
+ ws.send('__pong');
298
+ }
299
+ catch {
300
+ /* disconnected */
301
+ }
302
+ return;
303
+ }
300
304
  const attachment = this.attachments.get(ws);
301
305
  if (!attachment)
302
306
  return;
@@ -309,10 +313,6 @@ export class GameRoom {
309
313
  return;
310
314
  }
311
315
  const msgType = parsed.type;
312
- if (msgType === '__ping') {
313
- this.sendTo(ws, { type: '__pong' });
314
- return;
315
- }
316
316
  console.log(`[GameRoom] ⬅ recv from=${senderId} type=${msgType}`);
317
317
  if (msgType === '__action') {
318
318
  if (!this.gameState) {
@@ -383,11 +383,6 @@ export class GameRoom {
383
383
  // 値が変わると、片方だけを見た state が一瞬矛盾する。
384
384
  const now = Date.now();
385
385
  // handler 内では配列を触るだけにして、反映は dispatch 後にまとめて行う。
386
- const draft = new ScheduleDraft(now, (a) => this.hasAction(a));
387
- const scheduleCtx = {
388
- schedule: (o) => draft.schedule(o),
389
- unschedule: (k) => draft.unschedule(k),
390
- };
391
386
  try {
392
387
  // 決定的な部分を先に走らせ、serverActions がその結果を見られるようにする。
393
388
  if (plain && !legacyServerOnly) {
@@ -395,7 +390,7 @@ export class GameRoom {
395
390
  state: this.gameState,
396
391
  payload: payload ?? {},
397
392
  playerId: senderId,
398
- ctx: { now, emit, ...scheduleCtx },
393
+ ctx: { now, emit },
399
394
  });
400
395
  }
401
396
  if (serverHandler) {
@@ -408,7 +403,6 @@ export class GameRoom {
408
403
  random: this.random ?? new SeededRandomImpl(this.seed),
409
404
  now,
410
405
  emit: serverEmit,
411
- ...scheduleCtx,
412
406
  },
413
407
  });
414
408
  }
@@ -417,78 +411,103 @@ export class GameRoom {
417
411
  this.gameState = snapshot;
418
412
  throw err;
419
413
  }
420
- if (draft.dirty)
421
- await this.flushSchedule(draft);
414
+ this.syncWakeup();
422
415
  this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
423
416
  }
424
- // ─── Scheduler (本番の Durable Object alarm 相当) ──────────
425
- /** dev はメモリ + setTimeout。本番は storage + alarm */
426
- scheduled = [];
417
+ // ─── Deadlines (本番の Durable Object alarm 相当) ──────────
418
+ /** dev setTimeout。本番は storage alarm。意味論は同じ。 */
427
419
  wakeupTimer = null;
428
- get schedulerStore() {
429
- return {
430
- get: async () => this.scheduled,
431
- put: async (entries) => {
432
- this.scheduled = entries;
433
- },
434
- setWakeup: async (at) => {
435
- if (this.wakeupTimer)
436
- clearTimeout(this.wakeupTimer);
437
- this.wakeupTimer = null;
438
- if (at == null)
439
- return;
440
- this.wakeupTimer = setTimeout(() => void this.fireDue(), Math.max(0, at - Date.now()));
441
- },
442
- };
443
- }
444
- hasAction(name) {
445
- return Boolean(this.logic.actions[name] || this.logic.serverActions?.[name]);
446
- }
420
+ wakeupAt = null;
447
421
  /**
448
- * 予約の書き込みを直列化する。
449
- *
450
- * tick / action / 発火処理が同時に走ると、後着が先着を上書きして予約が消える。
451
- * 呼び出し順に 1 本の鎖へ並べる (play-server 版と同じ)。
422
+ * 宣言された締切のうち最も早い時刻。締切が無ければ null。
423
+ * state から毎回導出するので保存しない。
452
424
  */
453
- scheduleChain = Promise.resolve();
454
- queueScheduleWrite(mutate) {
455
- this.scheduleChain = this.scheduleChain
456
- .then(async () => {
457
- const current = await loadQueue(this.schedulerStore);
458
- await saveQueue(this.schedulerStore, mutate(current));
459
- })
460
- .catch((err) => {
461
- console.error('[Scheduler] ❌ 予約の書き込みに失敗', err);
462
- });
463
- return this.scheduleChain;
425
+ nextDeadline() {
426
+ if (!this.gameState || !this.logic.deadlines)
427
+ return null;
428
+ let earliest = null;
429
+ for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
430
+ let at;
431
+ try {
432
+ at = deadline.at({ state: this.gameState });
433
+ }
434
+ catch (err) {
435
+ console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
436
+ continue;
437
+ }
438
+ if (typeof at !== 'number' || !Number.isFinite(at))
439
+ continue;
440
+ if (earliest === null || at < earliest)
441
+ earliest = at;
442
+ }
443
+ return earliest;
464
444
  }
465
- flushSchedule(draft) {
466
- return this.queueScheduleWrite((current) => draft.apply(current));
445
+ /** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
446
+ syncWakeup() {
447
+ const next = this.nextDeadline();
448
+ if (next === this.wakeupAt)
449
+ return;
450
+ if (this.wakeupTimer)
451
+ clearTimeout(this.wakeupTimer);
452
+ this.wakeupTimer = null;
453
+ this.wakeupAt = next;
454
+ if (next === null)
455
+ return;
456
+ this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
457
+ // dev の setTimeout は process を掴み続けるので、他に仕事が無ければ抜けられるようにする。
458
+ this.wakeupTimer.unref?.();
467
459
  }
468
460
  /**
469
- * 期限の来た予約を実行する。本番の alarm() と同じ意味論。
461
+ * 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
470
462
  *
471
- * 失敗は握って捨てる (決定的バグでクラッシュを繰り返さない)。ログには必ず残す。
463
+ * handler の直前に at を評価し直す (先に走った handler が別の締切を消しうる)
464
+ * 失敗は握って捨てる。ログには必ず残す。
472
465
  */
473
- async fireDue() {
474
- let due = [];
475
- // 先に残りを確定させる。実行中に新しい予約が積まれても失われない。
476
- await this.queueScheduleWrite((current) => {
477
- const split = popDue(current, Date.now());
478
- due = split.due;
479
- return split.rest;
480
- });
481
- if (due.length > 0) {
482
- console.log(`[Scheduler] ${due.length} 件発火: ${due.map((e) => e.key).join(', ')}`);
483
- }
484
- for (const entry of due) {
466
+ fireDue() {
467
+ this.wakeupTimer = null;
468
+ this.wakeupAt = null;
469
+ if (!this.gameState || !this.logic.deadlines)
470
+ return;
471
+ const now = Date.now();
472
+ const events = [];
473
+ const firedKeys = [];
474
+ for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
475
+ // 巻き戻しで this.gameState が差し替わるので、毎周読み直す。
476
+ const state = this.gameState;
477
+ if (!state)
478
+ break;
479
+ // handler が emit してから throw したときに捨てられるよう、締切ごとに溜める。
480
+ // state を戻したのに音や演出だけ流れると、起きていない出来事が見えてしまう。
481
+ const pending = [];
482
+ let snapshot = null;
485
483
  try {
486
- await this.dispatchAction(entry.action, entry.payload, SCHEDULED_ACTOR);
484
+ const at = deadline.at({ state });
485
+ if (typeof at !== 'number' || !Number.isFinite(at) || at > now)
486
+ continue;
487
+ // 期限が来たものだけ複製する。毎周撮ると締切の数だけ state の複製が走る。
488
+ snapshot = structuredClone(state);
489
+ deadline.handler({
490
+ state,
491
+ ctx: {
492
+ now,
493
+ random: this.random ?? new SeededRandomImpl(this.seed),
494
+ emit: (name, data) => pending.push({ name, data: data ?? {} }),
495
+ },
496
+ });
497
+ events.push(...pending);
498
+ firedKeys.push(key);
487
499
  }
488
500
  catch (err) {
489
- console.error(`[Scheduler] key=${entry.key} action=${entry.action}:`, err);
501
+ if (snapshot !== null)
502
+ this.gameState = snapshot;
503
+ console.error(`[Deadline] ❌ ${key} で例外`, err);
490
504
  }
491
505
  }
506
+ this.syncWakeup();
507
+ if (firedKeys.length === 0)
508
+ return;
509
+ console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(', ')}`);
510
+ this.broadcastStateDelta(events, {});
492
511
  }
493
512
  handleClose(ws) {
494
513
  const attachment = this.attachments.get(ws);
@@ -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 {};
@@ -25,6 +25,17 @@ export class RelayRoom {
25
25
  ws.send(JSON.stringify({ type: '__room_init', myId: playerId }));
26
26
  }
27
27
  handleMessage(ws, msg) {
28
+ // SDK は生文字列で送受信する (本番の setWebSocketAutoResponse と同じ wire 形式)。
29
+ // JSON.parse より前に返さないと heartbeat が落ちて 35 秒ごとに全席が再接続する。
30
+ if (msg === '__ping') {
31
+ try {
32
+ ws.send('__pong');
33
+ }
34
+ catch {
35
+ /* disconnected */
36
+ }
37
+ return;
38
+ }
28
39
  const attachment = this.attachments.get(ws);
29
40
  if (!attachment)
30
41
  return;
@@ -36,15 +47,6 @@ export class RelayRoom {
36
47
  catch {
37
48
  return;
38
49
  }
39
- if (parsed.type === '__ping') {
40
- try {
41
- ws.send(JSON.stringify({ type: '__pong' }));
42
- }
43
- catch {
44
- /* disconnected */
45
- }
46
- return;
47
- }
48
50
  console.log(`[RelayRoom] ⬅ recv from=${senderId}`, JSON.stringify(parsed));
49
51
  const outData = JSON.stringify({ ...parsed, __from: senderId });
50
52
  if (parsed.__to && typeof parsed.__to === 'string') {
@@ -78,6 +78,17 @@ export class SyncRoom {
78
78
  }
79
79
  }
80
80
  handleMessage(ws, msg) {
81
+ // SDK は生文字列で送受信する (本番の setWebSocketAutoResponse と同じ wire 形式)。
82
+ // JSON.parse より前に返さないと heartbeat が落ちて 35 秒ごとに全席が再接続する。
83
+ if (msg === '__ping') {
84
+ try {
85
+ ws.send('__pong');
86
+ }
87
+ catch {
88
+ /* disconnected */
89
+ }
90
+ return;
91
+ }
81
92
  const attachment = this.attachments.get(ws);
82
93
  if (!attachment)
83
94
  return;
@@ -90,15 +101,6 @@ export class SyncRoom {
90
101
  return;
91
102
  }
92
103
  const msgType = parsed.type;
93
- if (msgType === '__ping') {
94
- try {
95
- ws.send(JSON.stringify({ type: '__pong' }));
96
- }
97
- catch {
98
- /* disconnected */
99
- }
100
- return;
101
- }
102
104
  console.log(`[SyncRoom] ⬅ recv from=${senderId} type=${msgType}`);
103
105
  if (msgType === '__init_state') {
104
106
  if (this.stateInitialized) {
@@ -33,6 +33,14 @@ const PHONE_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" x
33
33
  <rect x="6.5" y="2" width="11" height="20" rx="2.5" stroke="white" stroke-width="2"/>
34
34
  <line x1="10" y1="18.5" x2="14" y2="18.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
35
35
  </svg>`;
36
+ // 擬似ノッチ表示トグル。 createNotchDecor が実際に描く装飾 (横向き端末・左の
37
+ // Dynamic Island・下中央のホームインジケータ) をそのまま象る。 隣の PHONE_SVG
38
+ // (縦向き) とは向きで見分ける。
39
+ const NOTCH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
40
+ <rect x="2" y="4.5" width="20" height="15" rx="3.5" stroke="white" stroke-width="1.8"/>
41
+ <rect x="4.8" y="8.5" width="2.2" height="7" rx="1.1" fill="white"/>
42
+ <line x1="11" y1="17" x2="17" y2="17" stroke="white" stroke-width="1.6" stroke-linecap="round"/>
43
+ </svg>`;
36
44
  /** buildIframeUrl に渡す HUD の避け領域 (Flutter HudInsets と同じ計算)。 */
37
45
  function hudInsetParams(hasMenu, actionCount) {
38
46
  let x = HUD_LEFT;
@@ -429,19 +437,12 @@ function openPlayerSwitcher(opts, currentIndex, anchor) {
429
437
  };
430
438
  setTimeout(() => document.addEventListener('click', close), 0);
431
439
  }
432
- /**
433
- * 各 iframe (= 各プレイヤー画面) の左上に載せる overlay chrome。
434
- * 本番アプリ (Flutter GamePlayScreen) と同じ仕様:
435
- * SafeArea top:8 / left:12 → [UZU ボタン 44] [gap 6] [ActionBar ピル: chat, mic]。
436
- * UZU ボタンは dev メニュー (State/Reset/Emulator) + その player の「スマホで開く」を開く。
437
- * chat / mic は本番の見た目に合わせた dev 表示 (機能は SDK 側が担うため配線しない)。
438
- */
439
440
  function createCellChrome(opts, playerIndex,
440
441
  // facade=true (実機の単体表示) では見た目だけ揃える。 admin channel が無いので
441
442
  // UZU メニュー / 実機 QR は配線せず、 アイコンはタップ無効 (ハリボテ)。
442
443
  facade = false,
443
- // grid では no-op のマイクアイコンを擬似ノッチ表示トグルに転用する。
444
- onMicClick,
444
+ // grid では no-op のマイク枠を擬似ノッチ表示トグルに転用する。
445
+ notchToggle,
445
446
  // 単体表示 (実機) では UZU ボタンをプレイヤー切替に転用する。
446
447
  onUzuClick) {
447
448
  const wrap = document.createElement('div');
@@ -489,17 +490,24 @@ onUzuClick) {
489
490
  'border-radius:18px',
490
491
  'display:flex;align-items:center',
491
492
  ].join(';');
492
- // 1つめ: マイク。 本番は音声トグルだが harness では no-op なので、 grid では
493
- // 擬似ノッチ表示のトグルに転用する (単体表示 facade では装飾のみ)。
494
- const mic = document.createElement(onMicClick ? 'button' : 'div');
495
- mic.innerHTML = MIC_SVG;
496
- mic.title = onMicClick ? '擬似ノッチ表示 ON/OFF' : 'マイク (dev 表示のみ)';
497
- mic.style.cssText = onMicClick
493
+ // 1つめ: 本番は音声トグルだが harness には音声が無い。 grid ではこの枠を擬似ノッチ
494
+ // 表示のトグルに転用し、 アイコンも端末フレームへ差し替える (facade は本番の見た目)。
495
+ const micSlot = document.createElement(notchToggle ? 'button' : 'div');
496
+ micSlot.innerHTML = notchToggle ? NOTCH_SVG : MIC_SVG;
497
+ micSlot.style.cssText = notchToggle
498
498
  ? 'padding:0 10px;height:36px;background:none;border:none;cursor:pointer;display:flex;align-items:center;'
499
499
  : 'padding:0 10px;display:flex;align-items:center;';
500
- if (onMicClick)
501
- mic.onclick = onMicClick;
502
- bar.appendChild(mic);
500
+ if (notchToggle) {
501
+ micSlot.onclick = notchToggle.toggle;
502
+ notchToggle.register((on) => {
503
+ micSlot.style.opacity = on ? '1' : '0.4';
504
+ micSlot.title = `擬似ノッチ表示: ${on ? 'ON' : 'OFF'}`;
505
+ });
506
+ }
507
+ else {
508
+ micSlot.title = 'マイク (dev 表示のみ)';
509
+ }
510
+ bar.appendChild(micSlot);
503
511
  // 2つめ: 携帯 (その player を実機で開く QR)。 facade では装飾のみ。
504
512
  const phone = document.createElement('button');
505
513
  phone.innerHTML = PHONE_SVG;
@@ -528,7 +536,8 @@ export function mountIframeGrid(opts) {
528
536
  const iframeSplashes = new Map();
529
537
  const iframePredictionWarns = new Map();
530
538
  // 擬似ノッチ (各 cell 共通トグル)。 iframe を再生成せず inset だけ切り替える。
531
- let notchSimOn = true;
539
+ // 既定は OFF: ノッチ帯のぶん画面が縮むので、 SafeArea を確認したいときだけ出す。
540
+ let notchSimOn = false;
532
541
  const notchAppliers = [];
533
542
  const toggleNotchSim = () => {
534
543
  notchSimOn = !notchSimOn;
@@ -575,6 +584,7 @@ export function mountIframeGrid(opts) {
575
584
  iframeStatusDots.clear();
576
585
  iframeSplashes.clear();
577
586
  iframePredictionWarns.clear();
587
+ notchAppliers.length = 0;
578
588
  for (let i = 0; i < opts.seats.length; i++) {
579
589
  const seat = seatAt(opts, i);
580
590
  const cell = document.createElement('div');
@@ -616,7 +626,13 @@ export function mountIframeGrid(opts) {
616
626
  screen.appendChild(splash);
617
627
  // admin は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
618
628
  if (seat.kind !== 'admin') {
619
- screen.appendChild(createCellChrome(opts, i, false, toggleNotchSim));
629
+ screen.appendChild(createCellChrome(opts, i, false, {
630
+ toggle: toggleNotchSim,
631
+ register: (apply) => {
632
+ apply(notchSimOn);
633
+ notchAppliers.push(apply);
634
+ },
635
+ }));
620
636
  }
621
637
  // 先読み警告の内訳パネル。 バッジクリックで開閉する。 画面の上に重ねるので
622
638
  // ゲームの見た目は普段どおりのまま、 必要なときだけ前に出る。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-cli",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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;