def-game 6.1.0 → 6.2.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/README.md CHANGED
@@ -5,10 +5,10 @@
5
5
 
6
6
  ## Getting Started
7
7
 
8
- Node.js 22以降が必要です。v6.1.0の利用手順です。
8
+ Node.js 22以降が必要です。v6.2.0の利用手順です。
9
9
 
10
10
  ```sh
11
- npx def-game@6.1.0 init-worker --directory my-game --name my-game
11
+ npx def-game@6.2.0 init-worker --directory my-game --name my-game
12
12
  cd my-game
13
13
  npm install
14
14
  npm run dev
@@ -1 +1 @@
1
- {"version":3,"file":"session-runtime.d.ts","sourceRoot":"","sources":["../../../src/cloudflare/session-runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGnD,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAgD,aAAa,EAAE,MAAM,YAAY,CAAC;AAMtK;;;GAGG;AACH,8BAAsB,cAAc,CAAC,GAAG,EAAE,CAAC,SAAS,SAAS,CAAE,SAAQ,aAAa,CAAC,GAAG,CAAC;;IACvF,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IAEzD,oCAAoC;IAC9B,MAAM,IAAI,OAAO,CAAC,gBAAgB,CAAC;IAgBzC,iDAAiD;IAC3C,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;IActE,wCAAwC;IAClC,aAAa,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAKzG,oDAAoD;IAC9C,cAAc,CAAC,OAAO,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAgFrF,0CAA0C;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAK5B,uDAAuD;IACjD,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAyBhD,8CAA8C;IACxC,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAiEnF,cAAc,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAGrD,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI;CACxC"}
1
+ {"version":3,"file":"session-runtime.d.ts","sourceRoot":"","sources":["../../../src/cloudflare/session-runtime.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAGnD,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,WAAW,EAAE,SAAS,EAAkD,aAAa,EAAE,MAAM,YAAY,CAAC;AAMxK;;;GAGG;AACH,8BAAsB,cAAc,CAAC,GAAG,EAAE,CAAC,SAAS,SAAS,CAAE,SAAQ,aAAa,CAAC,GAAG,CAAC;;IACvF,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IAEzD,oCAAoC;IAC9B,MAAM,IAAI,OAAO,CAAC,gBAAgB,CAAC;IAgBzC,iDAAiD;IAC3C,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;IActE,wCAAwC;IAClC,aAAa,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC,cAAc,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAKzG,oDAAoD;IAC9C,cAAc,CAAC,OAAO,EAAE,CAAC,CAAC,eAAe,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAsFrF,0CAA0C;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA0B5B,uDAAuD;IACjD,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAyBhD,8CAA8C;IACxC,gBAAgB,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAiEnF,cAAc,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAGrD,cAAc,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI;CACxC"}
@@ -1,7 +1,7 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
2
  import { failure, isRecord, parseCommandRequest } from "./protocol.js";
3
3
  const STATE_KEY = "game-state";
4
- const TIMEOUT_KEY = "decision-timeout";
4
+ const SCHEDULED_EVENTS_KEY = "scheduled-events";
5
5
  /**
6
6
  * 1ルームを動かすCloudflare専用runtime。アプリはadapterだけを接続した派生クラスをexportする。
7
7
  * RPCとfetchは信頼されたWorker専用。認証・外部リクエスト保護はWorker入口が担当する。
@@ -60,17 +60,20 @@ export class SessionRuntime extends DurableObject {
60
60
  try {
61
61
  let consumed;
62
62
  if (options.alarm) {
63
- const reservation = await this.ctx.storage.get(TIMEOUT_KEY);
64
- if (!reservation)
63
+ const reservations = await this.#reservations(this.ctx.storage);
64
+ const reservation = this.#earliest(reservations);
65
+ if (!reservation) {
66
+ await this.ctx.storage.deleteAlarm();
65
67
  return { ok: true };
68
+ }
66
69
  if (Date.now() < reservation.deadline) {
67
70
  await this.ctx.storage.setAlarm(reservation.deadline);
68
71
  return { ok: true };
69
72
  }
70
- if (!this.adapter.timeout)
71
- throw new Error("Timeout adapter missing");
72
- command = this.adapter.timeout.command(reservation.decisionId);
73
- consumed = reservation.decisionId;
73
+ if (!this.adapter.scheduler)
74
+ throw new Error("Scheduler adapter missing");
75
+ command = this.adapter.scheduler.command(reservation.id);
76
+ consumed = reservation.id;
74
77
  }
75
78
  const state = await this.ctx.storage.get(STATE_KEY);
76
79
  if (state === undefined)
@@ -84,41 +87,44 @@ export class SessionRuntime extends DurableObject {
84
87
  if (result.state === undefined)
85
88
  throw new Error("State must be persistable");
86
89
  const external = [];
87
- const alarms = [];
90
+ const scheduled = [];
88
91
  for (const effect of result.effects) {
89
- const alarm = this.adapter.timeout?.effect(effect) ?? null;
90
- if (alarm === null)
92
+ const scheduledEffect = this.adapter.scheduler?.effect(effect) ?? null;
93
+ if (scheduledEffect === null)
91
94
  external.push(effect);
92
95
  else {
93
- if (!alarm.decisionId || (alarm.type !== "schedule" && alarm.type !== "cancel")
94
- || (alarm.type === "schedule" && !Number.isFinite(alarm.deadline)))
95
- throw new Error("Invalid alarm effect");
96
- alarms.push(alarm);
96
+ if (!scheduledEffect.id || (scheduledEffect.type !== "schedule" && scheduledEffect.type !== "cancel")
97
+ || (scheduledEffect.type === "schedule" && !Number.isFinite(scheduledEffect.deadline))) {
98
+ throw new Error("Invalid scheduler effect");
99
+ }
100
+ scheduled.push(scheduledEffect);
97
101
  }
98
102
  }
99
103
  await this.ctx.storage.transaction(async (txn) => {
100
104
  await txn.put(STATE_KEY, result.state);
101
- if (consumed !== undefined) {
102
- const current = await txn.get(TIMEOUT_KEY);
103
- if (current?.decisionId === consumed) {
104
- await txn.delete(TIMEOUT_KEY);
105
- await txn.deleteAlarm();
105
+ if (consumed !== undefined || scheduled.length > 0) {
106
+ let reservations = await this.#reservations(txn);
107
+ if (consumed !== undefined)
108
+ reservations = reservations.filter(item => item.id !== consumed);
109
+ for (const scheduledEffect of scheduled) {
110
+ reservations = reservations.filter(item => item.id !== scheduledEffect.id);
111
+ if (scheduledEffect.type === "schedule") {
112
+ reservations.push({ id: scheduledEffect.id, deadline: scheduledEffect.deadline });
113
+ }
106
114
  }
107
- }
108
- for (const alarm of alarms) {
109
- if (alarm.type === "schedule") {
110
- await txn.put(TIMEOUT_KEY, { decisionId: alarm.decisionId, deadline: alarm.deadline });
111
- await txn.setAlarm(alarm.deadline);
115
+ const earliest = this.#earliest(reservations);
116
+ if (earliest) {
117
+ await txn.put(SCHEDULED_EVENTS_KEY, reservations);
118
+ await txn.setAlarm(earliest.deadline);
112
119
  }
113
120
  else {
114
- const current = await txn.get(TIMEOUT_KEY);
115
- if (current?.decisionId === alarm.decisionId) {
116
- await txn.delete(TIMEOUT_KEY);
117
- await txn.deleteAlarm();
118
- }
121
+ await txn.delete(SCHEDULED_EVENTS_KEY);
122
+ await txn.deleteAlarm();
119
123
  }
120
124
  }
121
125
  });
126
+ if (consumed !== undefined)
127
+ options.onScheduledProcessed?.();
122
128
  // 保存後の配信失敗で、確定したCommandを失敗に戻さない。
123
129
  effects = external;
124
130
  this.#broadcast(result.state);
@@ -136,9 +142,28 @@ export class SessionRuntime extends DurableObject {
136
142
  }
137
143
  /** Alarm失敗は例外としてCloudflareの有限回リトライへ返す。 */
138
144
  async alarm() {
139
- const result = await this.#dispatchCommand(undefined, { origin: "system" }, { alarm: true });
140
- if (!result.ok)
141
- throw new Error("Timeout command failed");
145
+ while (true) {
146
+ let processed = false;
147
+ const result = await this.#dispatchCommand(undefined, { origin: "system" }, { alarm: true, onScheduledProcessed: () => { processed = true; } });
148
+ if (!result.ok)
149
+ throw new Error("Scheduled command failed");
150
+ if (!processed)
151
+ return;
152
+ }
153
+ }
154
+ async #reservations(storage) {
155
+ const reservations = await storage.get(SCHEDULED_EVENTS_KEY);
156
+ if (reservations === undefined)
157
+ return [];
158
+ if (!Array.isArray(reservations) || reservations.some(item => !isRecord(item)
159
+ || typeof item.id !== "string" || item.id.length === 0
160
+ || typeof item.deadline !== "number" || !Number.isFinite(item.deadline))) {
161
+ throw new Error("Invalid scheduled reservations");
162
+ }
163
+ return reservations;
164
+ }
165
+ #earliest(reservations) {
166
+ return reservations.reduce((earliest, item) => !earliest || item.deadline < earliest.deadline ? item : earliest, undefined);
142
167
  }
143
168
  /** 認証済みWorkerからだけ呼ぶ内部WS入口。一般HTTP Commandの転送は受け付けない。 */
144
169
  async fetch(request) {
@@ -41,14 +41,14 @@ export type GetViewResult<View> = {
41
41
  readonly ok: false;
42
42
  readonly error: RuntimeError;
43
43
  };
44
- /** 状態と一緒に確定する、1つの現在decisionに対する予約・解除。 */
45
- export type TimeoutEffect = {
44
+ /** 状態と一緒に確定する、論理的な予定イベントの予約・解除。 */
45
+ export type ScheduledEffect = {
46
46
  readonly type: "schedule";
47
- readonly decisionId: string;
47
+ readonly id: string;
48
48
  readonly deadline: number;
49
49
  } | {
50
50
  readonly type: "cancel";
51
- readonly decisionId: string;
51
+ readonly id: string;
52
52
  };
53
53
  /** 失敗した区間を通知する。秘密の状態・Command・Effectを自動ログに含めない。 */
54
54
  export interface RuntimeFailure {
@@ -63,9 +63,9 @@ export interface GameAdapter<Env, T extends GameTypes> {
63
63
  readonly parseCommand: (input: unknown) => T["actorCommand"] | null;
64
64
  };
65
65
  readonly canConnect: (state: T["state"], actorId: string) => boolean;
66
- readonly timeout?: {
67
- readonly effect: (effect: T["effect"]) => TimeoutEffect | null;
68
- readonly command: (decisionId: string) => T["systemCommand"];
66
+ readonly scheduler?: {
67
+ readonly effect: (effect: T["effect"]) => ScheduledEffect | null;
68
+ readonly command: (id: string) => T["systemCommand"];
69
69
  };
70
70
  /** 保存後のbest-effort処理。結果はruntimeがSystem Commandとして再度dispatchする。 */
71
71
  readonly executeEffect?: (effect: T["effect"], context: {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/cloudflare/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/cloudflare/types.ts"],"names":[],"mappings":"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"}
@@ -16,14 +16,14 @@ flowchart TB
16
16
  subgraph App["初期生成するアプリ所有コード:編集・置き換え可能"]
17
17
  Entry["Worker入口:HTTP/WS接続ルート<br/>認証・公開ルームIDの解決"]
18
18
  API["HTTP処理・外部情報の取得"]
19
- Adapter["adapter:入力parser・接続可否・Alarm変換"]
19
+ Adapter["adapter:入力parser・接続可否・scheduler変換"]
20
20
  Handler["Effect handler"]
21
21
  Game["Game Definition<br/>初期状態・ルール・Actor別View"]
22
22
  end
23
23
  subgraph Lib["DefGameライブラリ:SessionRuntimeが提供"]
24
24
  WS["標準WS管理<br/>接続とActorの紐付け・Hibernation"]
25
25
  Runtime["共通dispatcher<br/>状態取得・Command実行・保存"]
26
- Alarm["Alarm予約・発火時の処理"]
26
+ Alarm["永続scheduler<br/>論理予約・Alarm発火"]
27
27
  end
28
28
  External["外部サービス"]
29
29
  Client -->|HTTP・WS接続開始| Entry
@@ -34,7 +34,7 @@ flowchart TB
34
34
  Runtime -->|handleCommand・projectを呼ぶ| Game
35
35
  Game -->|次状態・Effect・View| Runtime
36
36
  Adapter -.->|入力・接続条件を提供| WS
37
- Adapter -.->|Alarm変換を提供| Alarm
37
+ Adapter -.->|予定イベント変換を提供| Alarm
38
38
  Runtime -->|状態と一緒に予約| Alarm
39
39
  Alarm -->|System Command| Runtime
40
40
  Runtime -->|保存後のActor別View| WS
@@ -52,10 +52,10 @@ WS接続後のメッセージはライブラリのWS処理へ届き、毎回Work
52
52
 
53
53
  ## 1. init-workerで開発を始める
54
54
 
55
- Node.js 22以降で、次を実行します。以下は`6.1.0`の利用手順です。
55
+ Node.js 22以降で、次を実行します。以下は`6.2.0`の利用手順です。
56
56
 
57
57
  ```sh
58
- npx def-game@6.1.0 init-worker --directory my-game --name my-game
58
+ npx def-game@6.2.0 init-worker --directory my-game --name my-game
59
59
  cd my-game
60
60
  npm install
61
61
  npm run dev
@@ -195,12 +195,12 @@ webhookやマッチング処理から入力する場合は、アプリで認証
195
195
  | `game` | 必須 | 作成したGame Definitionを渡す |
196
196
  | `webSocket.parseCommand` | 必須 | unknownの入力を許可したActor Commandへ変換。拒否はnull |
197
197
  | `canConnect` | 必須 | StateとActor IDから接続・View配信の可否を判定 |
198
- | `timeout.effect`/`timeout.command` | 期限処理を使う場合 | Alarm用Effectの識別と、発火時のSystem Command作成 |
198
+ | `scheduler.effect`/`scheduler.command` | 予定イベントを使う場合 | Effectから論理予約への変換と、発火時のSystem Command作成 |
199
199
  | `executeEffect` | 外部Effectを出す場合 | 外部処理と、必要なら結果Commandの返却 |
200
200
  | `onError` | 独自の診断が必要な場合 | 失敗した区間の記録・通知 |
201
201
 
202
202
  `executeEffect`は型上は省略可能ですが、外部Effectを出すゲームでは対応するhandlerが必要です。
203
- Alarm用Effectはruntimeが状態と一緒に保存します。外部handlerから直接setAlarmする構成にはしません。
203
+ scheduler用Effectはruntimeが状態と一緒に保存します。外部handlerから直接setAlarmする構成にはしません。
204
204
 
205
205
  `src/room.ts`では、そのadapterを共通runtimeへ接続しています。
206
206
 
@@ -266,7 +266,7 @@ flowchart LR
266
266
  G -->|拒否| E["状態を変えずエラーを返す"]
267
267
  ```
268
268
 
269
- HTTP、WS、timeout、外部処理の完了がそれぞれ直接状態を書き換えると、どこにルールがあるのか追いにくくなります。
269
+ HTTP、WS、予定イベント、外部処理の完了がそれぞれ直接状態を書き換えると、どこにルールがあるのか追いにくくなります。
270
270
  DefGameでは初期状態の生成後、すべてのゲーム操作をCommandとして`handleCommand`へ集めます。
271
271
  同じ操作なら通信経路によらず同じルールを通り、変更理由をCommandとその処理から読めます。
272
272
  これは状態遷移の構造を揃える仕組みであり、Command履歴の永続保存や自動リプレイを提供するものではありません。
@@ -317,7 +317,7 @@ sequenceDiagram
317
317
  ただし、外部Effectはbest effortです。状態保存後、handlerが起動する前に停止すれば依頼が失われる可能性があります。
318
318
  外部処理の完了から結果Commandの保存までにも同様の隙間があります。
319
319
  ライブラリは永続outboxや自動再試行を提供せず、Effect失敗で保存済み状態を取り消しません。
320
- 結果が必須の機能では、アプリ側の復旧方法やゲーム側のtimeoutを設計してください。
320
+ 結果が必須の機能では、アプリ側の復旧方法やゲーム側の予定イベントを設計してください。
321
321
 
322
322
  ### 保存・Alarm・配信の手順を共通化する
323
323
 
@@ -325,36 +325,40 @@ sequenceDiagram
325
325
  flowchart TB
326
326
  Input["プレイヤーの操作"] --> G["handleCommand"]
327
327
  G --> Wait["安定した入力待ち状態<br/>誰の入力・どのdecision IDを待つか"]
328
- G --> Effect["Alarm用Effect<br/>decision ID・期限"]
328
+ G --> Effect["scheduler用Effect<br/>イベントID・期限"]
329
329
  subgraph TX["同じstorage transactionで確定"]
330
330
  State["入力待ち状態を保存"]
331
- Reservation["予約情報とCloudflare Alarmを設定"]
331
+ Reservations["N個の論理予約を保存"]
332
+ Alarm["最も早い期限を<br/>1つの物理Alarmへ設定"]
333
+ Reservations -->|min(deadline)| Alarm
332
334
  end
333
335
  Wait --> State
334
- Effect -->|adapterで識別| Reservation
336
+ Effect -->|adapterで識別| Reservations
335
337
  TX --> Saved["保存確定"]
336
338
  Saved --> View["View配信"]
337
339
  Saved --> Waiting["次の入力を待つ"]
338
340
  Waiting -->|期限前の操作| Next["Actor Command"]
339
- Waiting -->|Cloudflare Alarm発火| Timeout["System Command<br/>decision IDを付ける"]
341
+ Waiting -->|Cloudflare Alarm発火| Scheduled["System Command<br/>イベントIDを付ける"]
340
342
  Next --> Check["同じhandleCommand<br/>現在の入力待ちに有効か検証"]
341
- Timeout --> Check
343
+ Scheduled --> Check
342
344
  Check -->|有効| Transition["次の安定状態へ<br/>必要なら予約を取消・更新"]
343
345
  Check -->|古い入力等| Reject["現在状態を進めない"]
344
346
  ```
345
347
 
346
- 手番や選択待ちに期限を付けるとき、ゲームは「期限に達したら何をするか」を実装します。
347
- 予約の保存とCloudflareネイティブのAlarmへの接続はruntimeに任せられます。
348
- タイムアウトは任意の機能ですが、プレイヤーの離席や切断で進行が止まるゲームに役立ちます。
348
+ 手番や選択待ち、inactivity、再接続猶予などに期限を付けるとき、ゲームは「期限に達したら何をするか」を実装します。
349
+ 複数の論理予約の保存とCloudflareネイティブの1つのAlarmへの接続はruntimeに任せられます。
350
+ runtimeはイベントIDの意味を解釈せず、最も早い期限だけを物理Alarmへ設定します。
349
351
  図のtransactionは保存処理だけを囲んでいます。将来の発火・Command実行や、取り消せないView配信は含みません。
350
352
 
351
- 複数の入力が届いても、runtimeが状態取得から更新を直列化し、状態とAlarm予約を同じtransactionで保存します。
353
+ 複数の入力が届いても、runtimeが状態取得から更新を直列化し、状態と論理予約を同じtransactionで保存します。
352
354
  これにより、ゲームごとに同じ排他制御や保存手順を実装する負担を減らします。
353
355
  保存が確定してからViewを配信し、外部Effectは直列化区間を出て実行します。
354
356
  外部処理を待っている間にも次のCommandを処理し、結果が戻れば最新状態に対して検証します。
355
357
 
356
- Alarmの予約が状態と一緒に確定することと、将来のAlarm発火が一度だけ処理されることは別です。
357
- ゲーム側でもdecision IDと現在状態を確認し、古い入力を適用しないようにします。
358
+ Alarm発火時は期限に達した予約を1件ずつ通常のCommandとして処理し、そのたびに最新のStateと予約を読み直します。
359
+ 先のCommandがゲームを終了して次の予約を取り消せば、その予約は処理されません。
360
+ 論理予約が状態と一緒に確定することと、将来のAlarm発火が一度だけ処理されることは別です。
361
+ ゲーム側でもイベントIDと現在状態を確認し、古い入力を適用しないようにします。
358
362
 
359
363
  ### ゲームの参加状態を、通信の寿命から切り離す
360
364
 
@@ -62,9 +62,9 @@ export interface GameAdapter<Env, T extends GameTypes> {
62
62
  readonly parseCommand: (input: unknown) => T["actorCommand"] | null;
63
63
  };
64
64
  readonly canConnect: (state: T["state"], actorId: string) => boolean;
65
- readonly timeout?: {
66
- readonly effect: (effect: T["effect"]) => TimeoutEffect | null;
67
- readonly command: (decisionId: string) => T["systemCommand"];
65
+ readonly scheduler?: {
66
+ readonly effect: (effect: T["effect"]) => ScheduledEffect | null;
67
+ readonly command: (id: string) => T["systemCommand"];
68
68
  };
69
69
  /** 保存後のbest-effort処理。結果はruntimeがSystem Commandとして再度dispatchする。 */
70
70
  readonly executeEffect?: (effect: T["effect"], context: { readonly env: Env; readonly roomId: string })
@@ -79,7 +79,7 @@ export interface GameAdapter<Env, T extends GameTypes> {
79
79
  | `game` | 純粋な初期状態生成・ルール・View生成を提供 | 必須 |
80
80
  | `webSocket.parseCommand` | クライアントが指定してよい入力をCommandへ変換 | 必須 |
81
81
  | `canConnect` | Actorの接続とView配信を許可するか判断 | 必須 |
82
- | `timeout` | ゲームの期限処理をCloudflare Alarmにつなぐ | 期限処理を使う場合 |
82
+ | `scheduler` | 論理的な予定イベントをCloudflare Alarmにつなぐ | 予定イベントを使う場合 |
83
83
  | `executeEffect` | Alarm以外のEffectを実行し、必要なら結果を返す | 外部Effectを出す場合 |
84
84
  | `onError` | runtimeの診断情報を受け取る | 任意 |
85
85
 
@@ -222,37 +222,47 @@ HTTPから`dispatchActor`へ渡したCommandには、このWS parserは適用さ
222
222
  falseなら接続開始は403、WS Commandは`NotRoomMember`として拒否し、配信対象なら接続を閉じます。
223
223
  HTTP/SystemのCommand認可には使われないため、`handleCommand`の検証は必須です。
224
224
 
225
- ### timeout.effect / timeout.command
225
+ ### scheduler.effect / scheduler.command
226
226
 
227
227
  ```ts
228
- timeout?: {
229
- effect: (effect: T['effect']) => TimeoutEffect | null;
230
- command: (decisionId: string) => T['systemCommand'];
228
+ scheduler?: {
229
+ effect: (effect: T['effect']) => ScheduledEffect | null;
230
+ command: (id: string) => T['systemCommand'];
231
231
  };
232
232
 
233
- type TimeoutEffect =
234
- | { readonly type: 'schedule'; readonly decisionId: string; readonly deadline: number }
235
- | { readonly type: 'cancel'; readonly decisionId: string };
233
+ type ScheduledEffect =
234
+ | { readonly type: 'schedule'; readonly id: string; readonly deadline: number }
235
+ | { readonly type: 'cancel'; readonly id: string };
236
236
  ```
237
237
 
238
- **目的:** ゲームが返すEffectを、状態と一緒に保存するAlarm予約へ変換します。
239
- `effect`は成功した遷移の各Effectに対して呼ばれます。Alarm用なら上記の形に変換し、外部Effectなら`null`を返します。
240
- 変換したAlarm用Effectは`executeEffect`へは渡されません。
238
+ **目的:** ゲームが返すEffectを、状態と一緒に保存する論理的な予定イベントへ変換します。
239
+ `effect`は成功した遷移の各Effectに対して呼ばれます。予定イベント用なら上記の形に変換し、外部Effectなら`null`を返します。
240
+ 変換したEffectは`executeEffect`へは渡されません。
241
241
 
242
242
  | 戻り値 | runtimeの動作 |
243
243
  | --- | --- |
244
- | schedule | 現在予約を置き換え、State・予約・Alarmを同じtransactionで保存 |
245
- | cancel | 現在のdecisionIdと一致するときだけ予約・Alarmを削除 |
244
+ | schedule | 同じidの予約を追加または置き換え、最も早い期限を物理Alarmへ設定 |
245
+ | cancel | 同じidの予約を削除し、残る最も早い期限へ物理Alarmを再設定 |
246
246
  | null | 保存後に外部Effect handlerへ渡す |
247
247
 
248
- 1ルームの予約は1つです。`decisionId`は空でない文字列、`deadline`は有限のUnix時刻ミリ秒を指定します。
249
- 複数のAlarm用Effectは配列順に適用します。
248
+ 1ルームに複数の論理予約を保持できます。`id`は空でない文字列、`deadline`は有限のUnix時刻ミリ秒を指定します。
249
+ 予約はゲームStateとは別の`scheduled-events`へ永続化されます。Cloudflareの物理Alarmは1つだけで、常に最も早い論理予約の期限を指します。
250
+ 予約がなくなれば物理Alarmも削除されます。複数のscheduler Effectは配列順に適用します。
250
251
 
251
- `command`は期限に達した予約からSystem Commandを作ります。必要なら`now: Date.now()`等を追加してください。
252
- 実行時にゲーム側でも現在のdecision IDと状態を検証します。
253
- 早すぎる発火は再予約し、ゲームによる拒否・保存失敗では予約を消費せず例外を返します。
252
+ `command`は期限に達した論理予約の`id`からSystem Commandを作ります。必要なら`now: Date.now()`等を追加してください。
253
+ runtimeはイベント名の意味を解釈しません。decision timeout、inactivity、reconnect grace period等の名前と処理はアプリが決めます。
254
+ decision timeoutもschedulerの通常の利用例であり、runtime固有の特別な予約ではありません。
255
+
256
+ Alarm発火時は、期限に達した最も早い予約を1件だけCommandへ変換し、通常のdispatcherで処理します。
257
+ 成功後に予約を読み直し、別の予約も期限に達していれば最新Stateに対して次のCommandを処理します。
258
+ これにより、先のCommandが後続予約を取り消した場合、その予約は古いStateから作ったCommandとして実行されません。
259
+ 早すぎる発火は最も早い期限へ再設定し、ゲームによる拒否・保存失敗では予約を消費せず例外を返します。
254
260
  Alarmの予約と、将来の発火・再試行は別の実行です。一度だけ届く前提にしないでください。
255
261
 
262
+ セッション削除はschedulerとは別のruntime lifecycle操作です。予定イベントをきっかけに削除する場合も、
263
+ まずSystem Commandとしてゲームが最新Stateで削除の妥当性を判断し、その結果をruntimeが`storage.deleteAll()`等へ接続する境界にします。
264
+ イベントID自体へ削除の意味を持たせたり、ゲームからDurable Object Storageを直接操作したりしません。
265
+
256
266
  ### executeEffect
257
267
 
258
268
  ```ts
@@ -443,10 +453,10 @@ requestIdは応答の対応付け用で、永続的な重複排除には使い
443
453
 
444
454
  ### 始め方
445
455
 
446
- Node.js 22以降を使用します。`6.1.0`を指定して生成します。
456
+ Node.js 22以降を使用します。`6.2.0`を指定して生成します。
447
457
 
448
458
  ```sh
449
- npx def-game@6.1.0 init-worker --directory my-game --name my-game
459
+ npx def-game@6.2.0 init-worker --directory my-game --name my-game
450
460
  cd my-game
451
461
  npm install
452
462
  npm run dev
@@ -454,7 +464,7 @@ npm run dev
454
464
 
455
465
  開発版をこのリポジトリから試す場合は、リポジトリで`npm pack`を実行します。
456
466
  `node bin/def-game.cjs init-worker --directory /path/to/my-game --name my-game`で生成し、生成先で
457
- `npm install /absolute/path/to/def-game-6.1.0.tgz`を実行してください。
467
+ `npm install /absolute/path/to/def-game-6.2.0.tgz`を実行してください。
458
468
  その後は`npm run typecheck`、`npm run build`、`npm run dev`を利用できます。buildはWranglerのdry-runで、公開しません。
459
469
 
460
470
  生成先は新しいディレクトリに限定します。既存ディレクトリは空でも拒否します。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "def-game",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Pure game definitions, a simulator, and a Cloudflare Durable Object runtime for turn-based games",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@ npm install
12
12
  npm run dev
13
13
  ```
14
14
 
15
- ローカルの開発版では、`npm install`の代わりに`npm install /absolute/path/to/def-game-6.1.0.tgz`を実行してください。
15
+ ローカルの開発版では、`npm install`の代わりに`npm install /absolute/path/to/def-game-6.2.0.tgz`を実行してください。
16
16
  `http://127.0.0.1:8787`を開きます。別ブラウザープロファイルで共有URLを開くと、2人で試せます。
17
17
 
18
18
  実装前に[設計思想](node_modules/def-game/docs/design-philosophy.md)を読み、詳細は[runtime API・初期生成の使い方](node_modules/def-game/docs/runtime-api.md)を参照してください。
@@ -10,6 +10,6 @@
10
10
  "build": "wrangler deploy --dry-run --outdir dist",
11
11
  "deploy": "wrangler deploy"
12
12
  },
13
- "dependencies": { "def-game": "6.1.0", "hono": "^4.13.7", "jose": "^6.2.12" },
13
+ "dependencies": { "def-game": "6.2.0", "hono": "^4.13.7", "jose": "^6.2.12" },
14
14
  "devDependencies": { "@cloudflare/workers-types": "^5.20260911.1", "typescript": "^5.8.2", "wrangler": "^4.131.1" }
15
15
  }
@@ -17,9 +17,11 @@ export const adapter: GameAdapter<Env, Types> = {
17
17
  return null;
18
18
  } },
19
19
  canConnect: (state, actorId) => state.players.some(p => p.actorId === actorId),
20
- timeout: {
21
- effect: effect => effect.type === 'schedule' || effect.type === 'cancel' ? effect : null,
22
- command: decisionId => ({ type: 'decision-timeout', decisionId, now: Date.now() }),
20
+ scheduler: {
21
+ effect: effect => effect.type === 'schedule'
22
+ ? { type: 'schedule', id: `decision:${effect.decisionId}`, deadline: effect.deadline }
23
+ : effect.type === 'cancel' ? { type: 'cancel', id: `decision:${effect.decisionId}` } : null,
24
+ command: id => ({ type: 'decision-timeout', decisionId: id.slice('decision:'.length), now: Date.now() }),
23
25
  },
24
26
  async executeEffect(effect, { roomId }) {
25
27
  if (effect.type === 'finished') console.info('Game finished', { roomId });