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 +2 -2
- package/dist/worker/cloudflare/session-runtime.d.ts.map +1 -1
- package/dist/worker/cloudflare/session-runtime.js +57 -32
- package/dist/worker/cloudflare/types.d.ts +7 -7
- package/dist/worker/cloudflare/types.d.ts.map +1 -1
- package/docs/design-philosophy.md +24 -20
- package/docs/runtime-api.md +34 -24
- package/package.json +1 -1
- package/templates/starter/README.md +1 -1
- package/templates/starter/package.json +1 -1
- package/templates/starter/src/game-adapter.ts +5 -3
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
## Getting Started
|
|
7
7
|
|
|
8
|
-
Node.js 22以降が必要です。v6.
|
|
8
|
+
Node.js 22以降が必要です。v6.2.0の利用手順です。
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
npx def-game@6.
|
|
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,
|
|
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
|
|
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
|
|
64
|
-
|
|
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.
|
|
71
|
-
throw new Error("
|
|
72
|
-
command = this.adapter.
|
|
73
|
-
consumed = reservation.
|
|
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
|
|
90
|
+
const scheduled = [];
|
|
88
91
|
for (const effect of result.effects) {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
92
|
+
const scheduledEffect = this.adapter.scheduler?.effect(effect) ?? null;
|
|
93
|
+
if (scheduledEffect === null)
|
|
91
94
|
external.push(effect);
|
|
92
95
|
else {
|
|
93
|
-
if (!
|
|
94
|
-
|| (
|
|
95
|
-
throw new Error("Invalid
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
await txn.
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
/**
|
|
45
|
-
export type
|
|
44
|
+
/** 状態と一緒に確定する、論理的な予定イベントの予約・解除。 */
|
|
45
|
+
export type ScheduledEffect = {
|
|
46
46
|
readonly type: "schedule";
|
|
47
|
-
readonly
|
|
47
|
+
readonly id: string;
|
|
48
48
|
readonly deadline: number;
|
|
49
49
|
} | {
|
|
50
50
|
readonly type: "cancel";
|
|
51
|
-
readonly
|
|
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
|
|
67
|
-
readonly effect: (effect: T["effect"]) =>
|
|
68
|
-
readonly command: (
|
|
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":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAE5D,+CAA+C;AAC/C,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,OAAO,CAAC;IACf,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;IACvB,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,sCAAsC;AACtC,MAAM,WAAW,aAAa;IAAG,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE;AAE3D,4CAA4C;AAC5C,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAClE,cAAc,GAAG,mBAAmB,GAAG,gBAAgB,GAAG,eAAe,GAAG,eAAe,CAAA;CAAE,CAAC;AAChG,MAAM,MAAM,aAAa,CAAC,KAAK,IAAI;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IACzD,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE,YAAY,GAAG;QAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAA;KAAE,CAAC;CAClF,CAAC;AACF,MAAM,MAAM,gBAAgB,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACzE;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAAE,CAAC;AAEzD,0CAA0C;AAC1C,MAAM,MAAM,aAAa,CAAC,IAAI,IAAI;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAA;CAAE,GACxE;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,YAAY,CAAA;CAAE,CAAC;AAEzD,
|
|
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・接続可否・
|
|
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
|
|
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.
|
|
55
|
+
Node.js 22以降で、次を実行します。以下は`6.2.0`の利用手順です。
|
|
56
56
|
|
|
57
57
|
```sh
|
|
58
|
-
npx def-game@6.
|
|
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
|
-
| `
|
|
198
|
+
| `scheduler.effect`/`scheduler.command` | 予定イベントを使う場合 | Effectから論理予約への変換と、発火時のSystem Command作成 |
|
|
199
199
|
| `executeEffect` | 外部Effectを出す場合 | 外部処理と、必要なら結果Commandの返却 |
|
|
200
200
|
| `onError` | 独自の診断が必要な場合 | 失敗した区間の記録・通知 |
|
|
201
201
|
|
|
202
202
|
`executeEffect`は型上は省略可能ですが、外部Effectを出すゲームでは対応するhandlerが必要です。
|
|
203
|
-
|
|
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
|
|
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
|
-
|
|
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["
|
|
328
|
+
G --> Effect["scheduler用Effect<br/>イベントID・期限"]
|
|
329
329
|
subgraph TX["同じstorage transactionで確定"]
|
|
330
330
|
State["入力待ち状態を保存"]
|
|
331
|
-
|
|
331
|
+
Reservations["N個の論理予約を保存"]
|
|
332
|
+
Alarm["最も早い期限を<br/>1つの物理Alarmへ設定"]
|
|
333
|
+
Reservations -->|min(deadline)| Alarm
|
|
332
334
|
end
|
|
333
335
|
Wait --> State
|
|
334
|
-
Effect -->|adapterで識別|
|
|
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発火|
|
|
341
|
+
Waiting -->|Cloudflare Alarm発火| Scheduled["System Command<br/>イベントIDを付ける"]
|
|
340
342
|
Next --> Check["同じhandleCommand<br/>現在の入力待ちに有効か検証"]
|
|
341
|
-
|
|
343
|
+
Scheduled --> Check
|
|
342
344
|
Check -->|有効| Transition["次の安定状態へ<br/>必要なら予約を取消・更新"]
|
|
343
345
|
Check -->|古い入力等| Reject["現在状態を進めない"]
|
|
344
346
|
```
|
|
345
347
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
348
|
+
手番や選択待ち、inactivity、再接続猶予などに期限を付けるとき、ゲームは「期限に達したら何をするか」を実装します。
|
|
349
|
+
複数の論理予約の保存とCloudflareネイティブの1つのAlarmへの接続はruntimeに任せられます。
|
|
350
|
+
runtimeはイベントIDの意味を解釈せず、最も早い期限だけを物理Alarmへ設定します。
|
|
349
351
|
図のtransactionは保存処理だけを囲んでいます。将来の発火・Command実行や、取り消せないView配信は含みません。
|
|
350
352
|
|
|
351
|
-
複数の入力が届いても、runtime
|
|
353
|
+
複数の入力が届いても、runtimeが状態取得から更新を直列化し、状態と論理予約を同じtransactionで保存します。
|
|
352
354
|
これにより、ゲームごとに同じ排他制御や保存手順を実装する負担を減らします。
|
|
353
355
|
保存が確定してからViewを配信し、外部Effectは直列化区間を出て実行します。
|
|
354
356
|
外部処理を待っている間にも次のCommandを処理し、結果が戻れば最新状態に対して検証します。
|
|
355
357
|
|
|
356
|
-
Alarm
|
|
357
|
-
|
|
358
|
+
Alarm発火時は期限に達した予約を1件ずつ通常のCommandとして処理し、そのたびに最新のStateと予約を読み直します。
|
|
359
|
+
先のCommandがゲームを終了して次の予約を取り消せば、その予約は処理されません。
|
|
360
|
+
論理予約が状態と一緒に確定することと、将来のAlarm発火が一度だけ処理されることは別です。
|
|
361
|
+
ゲーム側でもイベントIDと現在状態を確認し、古い入力を適用しないようにします。
|
|
358
362
|
|
|
359
363
|
### ゲームの参加状態を、通信の寿命から切り離す
|
|
360
364
|
|
package/docs/runtime-api.md
CHANGED
|
@@ -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
|
|
66
|
-
readonly effect: (effect: T["effect"]) =>
|
|
67
|
-
readonly command: (
|
|
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
|
-
| `
|
|
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
|
-
###
|
|
225
|
+
### scheduler.effect / scheduler.command
|
|
226
226
|
|
|
227
227
|
```ts
|
|
228
|
-
|
|
229
|
-
effect: (effect: T['effect']) =>
|
|
230
|
-
command: (
|
|
228
|
+
scheduler?: {
|
|
229
|
+
effect: (effect: T['effect']) => ScheduledEffect | null;
|
|
230
|
+
command: (id: string) => T['systemCommand'];
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
-
type
|
|
234
|
-
| { readonly type: 'schedule'; readonly
|
|
235
|
-
| { readonly type: 'cancel'; readonly
|
|
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
|
|
239
|
-
`effect`は成功した遷移の各Effect
|
|
240
|
-
変換した
|
|
238
|
+
**目的:** ゲームが返すEffectを、状態と一緒に保存する論理的な予定イベントへ変換します。
|
|
239
|
+
`effect`は成功した遷移の各Effectに対して呼ばれます。予定イベント用なら上記の形に変換し、外部Effectなら`null`を返します。
|
|
240
|
+
変換したEffectは`executeEffect`へは渡されません。
|
|
241
241
|
|
|
242
242
|
| 戻り値 | runtimeの動作 |
|
|
243
243
|
| --- | --- |
|
|
244
|
-
| schedule |
|
|
245
|
-
| cancel |
|
|
244
|
+
| schedule | 同じidの予約を追加または置き換え、最も早い期限を物理Alarmへ設定 |
|
|
245
|
+
| cancel | 同じidの予約を削除し、残る最も早い期限へ物理Alarmを再設定 |
|
|
246
246
|
| null | 保存後に外部Effect handlerへ渡す |
|
|
247
247
|
|
|
248
|
-
1
|
|
249
|
-
|
|
248
|
+
1ルームに複数の論理予約を保持できます。`id`は空でない文字列、`deadline`は有限のUnix時刻ミリ秒を指定します。
|
|
249
|
+
予約はゲームStateとは別の`scheduled-events`へ永続化されます。Cloudflareの物理Alarmは1つだけで、常に最も早い論理予約の期限を指します。
|
|
250
|
+
予約がなくなれば物理Alarmも削除されます。複数のscheduler Effectは配列順に適用します。
|
|
250
251
|
|
|
251
|
-
`command
|
|
252
|
-
|
|
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.
|
|
456
|
+
Node.js 22以降を使用します。`6.2.0`を指定して生成します。
|
|
447
457
|
|
|
448
458
|
```sh
|
|
449
|
-
npx def-game@6.
|
|
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.
|
|
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
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
21
|
-
effect: effect => effect.type === 'schedule'
|
|
22
|
-
|
|
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 });
|