def-game 6.0.0-alpha.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は先行公開版です。
8
+ Node.js 22以降が必要です。v6.2.0の利用手順です。
9
9
 
10
10
  ```sh
11
- npx def-game@6.0.0-alpha.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,24 +1,26 @@
1
- import type { CommandResult, CreateRoomResult, VerifiedActor } from "./types.js";
1
+ import type { CommandResult, CreateRoomResult, GetViewResult, VerifiedActor } from "./types.js";
2
2
  /** DOの信頼されたRPC面。アプリは外部リクエストを直接この面へ転送しない。 */
3
- interface RoomTransport<ActorCommand, SystemCommand, Error> {
3
+ interface RoomTransport<ActorCommand, SystemCommand, Error, View> {
4
4
  create(): Promise<CreateRoomResult>;
5
+ getView(actor: VerifiedActor): Promise<GetViewResult<View>>;
5
6
  dispatchActor(actor: VerifiedActor, command: ActorCommand): Promise<CommandResult<Error>>;
6
7
  dispatchSystem(command: SystemCommand): Promise<CommandResult<Error>>;
7
8
  fetch(request: Request): Promise<Response>;
8
9
  }
9
10
  /** 通常のアプリ操作向け参照。System入口を含めない。 */
10
- export interface RoomClient<ActorCommand, Error> {
11
+ export interface RoomClient<ActorCommand, Error, View = unknown> {
11
12
  create(): Promise<CreateRoomResult>;
13
+ getView(actor: VerifiedActor): Promise<GetViewResult<View>>;
12
14
  dispatchActor(actor: VerifiedActor, command: ActorCommand): Promise<CommandResult<Error>>;
13
15
  connect(actor: VerifiedActor): Promise<Response>;
14
16
  }
15
17
  /** 対象DOを選び、認証済みActor向けのAPIだけを返す。IDの解決はアプリが行う。 */
16
- export declare function getRoom<A, S, E>(namespace: {
17
- get(id: DurableObjectId): RoomTransport<A, S, E>;
18
- }, id: DurableObjectId): RoomClient<A, E>;
18
+ export declare function getRoom<A, S, E, V>(namespace: {
19
+ get(id: DurableObjectId): RoomTransport<A, S, E, V>;
20
+ }, id: DurableObjectId): RoomClient<A, E, V>;
19
21
  /** 認証・権限確認済みのwebhookやマッチングからSystem Commandを渡す参照。 */
20
- export declare function getSystemRoom<A, S, E>(namespace: {
21
- get(id: DurableObjectId): RoomTransport<A, S, E>;
22
+ export declare function getSystemRoom<A, S, E, V>(namespace: {
23
+ get(id: DurableObjectId): RoomTransport<A, S, E, V>;
22
24
  }, id: DurableObjectId): {
23
25
  dispatchSystem(command: S): Promise<CommandResult<E>>;
24
26
  };
@@ -1 +1 @@
1
- {"version":3,"file":"room-client.d.ts","sourceRoot":"","sources":["../../../src/cloudflare/room-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEjF,4CAA4C;AAC5C,UAAU,aAAa,CAAC,YAAY,EAAE,aAAa,EAAE,KAAK;IACxD,MAAM,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACpC,aAAa,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IAC1F,cAAc,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IACtE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC5C;AAED,kCAAkC;AAClC,MAAM,WAAW,UAAU,CAAC,YAAY,EAAE,KAAK;IAC7C,MAAM,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACpC,aAAa,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IAC1F,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAClD;AAED,iDAAiD;AACjD,wBAAgB,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAC7B,SAAS,EAAE;IAAE,GAAG,CAAC,EAAE,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;CAAE,EAAE,EAAE,EAAE,eAAe,GACnF,UAAU,CAAC,CAAC,EAAE,CAAC,CAAC,CAYlB;AAED,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EACnC,SAAS,EAAE;IAAE,GAAG,CAAC,EAAE,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;CAAE,EAAE,EAAE,EAAE,eAAe,GACnF;IAAE,cAAc,CAAC,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAA;CAAE,CAG3D"}
1
+ {"version":3,"file":"room-client.d.ts","sourceRoot":"","sources":["../../../src/cloudflare/room-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhG,4CAA4C;AAC5C,UAAU,aAAa,CAAC,YAAY,EAAE,aAAa,EAAE,KAAK,EAAE,IAAI;IAC9D,MAAM,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACpC,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;IAC5D,aAAa,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IAC1F,cAAc,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IACtE,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC5C;AAED,kCAAkC;AAClC,MAAM,WAAW,UAAU,CAAC,YAAY,EAAE,KAAK,EAAE,IAAI,GAAG,OAAO;IAC7D,MAAM,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAAC;IACpC,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,CAAC;IAC5D,aAAa,CAAC,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC;IAC1F,OAAO,CAAC,KAAK,EAAE,aAAa,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CAClD;AAED,iDAAiD;AACjD,wBAAgB,OAAO,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAChC,SAAS,EAAE;IAAE,GAAG,CAAC,EAAE,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;CAAE,EAAE,EAAE,EAAE,eAAe,GACtF,UAAU,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAarB;AAED,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EACtC,SAAS,EAAE;IAAE,GAAG,CAAC,EAAE,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;CAAE,EAAE,EAAE,EAAE,eAAe,GACtF;IAAE,cAAc,CAAC,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAA;CAAE,CAG3D"}
@@ -3,6 +3,7 @@ export function getRoom(namespace, id) {
3
3
  const stub = namespace.get(id);
4
4
  return {
5
5
  create: () => stub.create(),
6
+ getView: actor => stub.getView(actor),
6
7
  dispatchActor: (actor, command) => stub.dispatchActor(actor, command),
7
8
  connect: actor => {
8
9
  if (!actor || typeof actor.actorId !== "string" || !actor.actorId)
@@ -1,5 +1,5 @@
1
1
  import { DurableObject } from "cloudflare:workers";
2
- import type { CommandResult, CreateRoomResult, GameAdapter, GameTypes, VerifiedActor } from "./types.js";
2
+ import type { CommandResult, CreateRoomResult, GetViewResult, GameAdapter, GameTypes, VerifiedActor } from "./types.js";
3
3
  /**
4
4
  * 1ルームを動かすCloudflare専用runtime。アプリはadapterだけを接続した派生クラスをexportする。
5
5
  * RPCとfetchは信頼されたWorker専用。認証・外部リクエスト保護はWorker入口が担当する。
@@ -9,6 +9,8 @@ export declare abstract class SessionRuntime<Env, T extends GameTypes> extends D
9
9
  protected abstract readonly adapter: GameAdapter<Env, T>;
10
10
  /** 所有者や参加者を要求せず、ゲームの初期状態だけを保存する。 */
11
11
  create(): Promise<CreateRoomResult>;
12
+ /** HTTP等からの読み取り。公開内容はprojectが決め、WS接続資格は要求しない。 */
13
+ getView(actor: VerifiedActor): Promise<GetViewResult<T["view"]>>;
12
14
  /** アプリが認証済みActorと、必要なら外部取得済み情報を渡す入口。 */
13
15
  dispatchActor(actor: VerifiedActor, command: T["actorCommand"]): Promise<CommandResult<T["error"]>>;
14
16
  /** webhook等の認証を済ませたサーバー専用入口。外部本文からoriginをコピーしない。 */
@@ -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,WAAW,EAAE,SAAS,EAAgD,aAAa,EAAE,MAAM,YAAY,CAAC;AAMvJ;;;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,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入口が担当する。
@@ -26,6 +26,23 @@ export class SessionRuntime extends DurableObject {
26
26
  }
27
27
  });
28
28
  }
29
+ /** HTTP等からの読み取り。公開内容はprojectが決め、WS接続資格は要求しない。 */
30
+ async getView(actor) {
31
+ if (!actor || typeof actor.actorId !== "string" || actor.actorId.length === 0)
32
+ return failure("InvalidRequest");
33
+ return this.ctx.blockConcurrencyWhile(async () => {
34
+ try {
35
+ const state = await this.ctx.storage.get(STATE_KEY);
36
+ if (state === undefined)
37
+ return failure("RoomNotFound");
38
+ return { ok: true, view: this.adapter.game.project(state, actor.actorId) };
39
+ }
40
+ catch {
41
+ this.#report("view");
42
+ return failure("InternalError");
43
+ }
44
+ });
45
+ }
29
46
  /** アプリが認証済みActorと、必要なら外部取得済み情報を渡す入口。 */
30
47
  async dispatchActor(actor, command) {
31
48
  if (!actor || typeof actor.actorId !== "string" || actor.actorId.length === 0)
@@ -43,17 +60,20 @@ export class SessionRuntime extends DurableObject {
43
60
  try {
44
61
  let consumed;
45
62
  if (options.alarm) {
46
- const reservation = await this.ctx.storage.get(TIMEOUT_KEY);
47
- 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();
48
67
  return { ok: true };
68
+ }
49
69
  if (Date.now() < reservation.deadline) {
50
70
  await this.ctx.storage.setAlarm(reservation.deadline);
51
71
  return { ok: true };
52
72
  }
53
- if (!this.adapter.timeout)
54
- throw new Error("Timeout adapter missing");
55
- command = this.adapter.timeout.command(reservation.decisionId);
56
- 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;
57
77
  }
58
78
  const state = await this.ctx.storage.get(STATE_KEY);
59
79
  if (state === undefined)
@@ -67,41 +87,44 @@ export class SessionRuntime extends DurableObject {
67
87
  if (result.state === undefined)
68
88
  throw new Error("State must be persistable");
69
89
  const external = [];
70
- const alarms = [];
90
+ const scheduled = [];
71
91
  for (const effect of result.effects) {
72
- const alarm = this.adapter.timeout?.effect(effect) ?? null;
73
- if (alarm === null)
92
+ const scheduledEffect = this.adapter.scheduler?.effect(effect) ?? null;
93
+ if (scheduledEffect === null)
74
94
  external.push(effect);
75
95
  else {
76
- if (!alarm.decisionId || (alarm.type !== "schedule" && alarm.type !== "cancel")
77
- || (alarm.type === "schedule" && !Number.isFinite(alarm.deadline)))
78
- throw new Error("Invalid alarm effect");
79
- 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);
80
101
  }
81
102
  }
82
103
  await this.ctx.storage.transaction(async (txn) => {
83
104
  await txn.put(STATE_KEY, result.state);
84
- if (consumed !== undefined) {
85
- const current = await txn.get(TIMEOUT_KEY);
86
- if (current?.decisionId === consumed) {
87
- await txn.delete(TIMEOUT_KEY);
88
- 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
+ }
89
114
  }
90
- }
91
- for (const alarm of alarms) {
92
- if (alarm.type === "schedule") {
93
- await txn.put(TIMEOUT_KEY, { decisionId: alarm.decisionId, deadline: alarm.deadline });
94
- 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);
95
119
  }
96
120
  else {
97
- const current = await txn.get(TIMEOUT_KEY);
98
- if (current?.decisionId === alarm.decisionId) {
99
- await txn.delete(TIMEOUT_KEY);
100
- await txn.deleteAlarm();
101
- }
121
+ await txn.delete(SCHEDULED_EVENTS_KEY);
122
+ await txn.deleteAlarm();
102
123
  }
103
124
  }
104
125
  });
126
+ if (consumed !== undefined)
127
+ options.onScheduledProcessed?.();
105
128
  // 保存後の配信失敗で、確定したCommandを失敗に戻さない。
106
129
  effects = external;
107
130
  this.#broadcast(result.state);
@@ -119,9 +142,28 @@ export class SessionRuntime extends DurableObject {
119
142
  }
120
143
  /** Alarm失敗は例外としてCloudflareの有限回リトライへ返す。 */
121
144
  async alarm() {
122
- const result = await this.#dispatchCommand(undefined, { origin: "system" }, { alarm: true });
123
- if (!result.ok)
124
- 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);
125
167
  }
126
168
  /** 認証済みWorkerからだけ呼ぶ内部WS入口。一般HTTP Commandの転送は受け付けない。 */
127
169
  async fetch(request) {
@@ -33,14 +33,22 @@ export type CreateRoomResult = {
33
33
  readonly ok: false;
34
34
  readonly error: RuntimeError;
35
35
  };
36
- /** 状態と一緒に確定する、1つの現在decisionに対する予約・解除。 */
37
- export type TimeoutEffect = {
36
+ /** 保存状態そのものではなく、ゲームがActorに公開するViewを返す。 */
37
+ export type GetViewResult<View> = {
38
+ readonly ok: true;
39
+ readonly view: View;
40
+ } | {
41
+ readonly ok: false;
42
+ readonly error: RuntimeError;
43
+ };
44
+ /** 状態と一緒に確定する、論理的な予定イベントの予約・解除。 */
45
+ export type ScheduledEffect = {
38
46
  readonly type: "schedule";
39
- readonly decisionId: string;
47
+ readonly id: string;
40
48
  readonly deadline: number;
41
49
  } | {
42
50
  readonly type: "cancel";
43
- readonly decisionId: string;
51
+ readonly id: string;
44
52
  };
45
53
  /** 失敗した区間を通知する。秘密の状態・Command・Effectを自動ログに含めない。 */
46
54
  export interface RuntimeFailure {
@@ -55,9 +63,9 @@ export interface GameAdapter<Env, T extends GameTypes> {
55
63
  readonly parseCommand: (input: unknown) => T["actorCommand"] | null;
56
64
  };
57
65
  readonly canConnect: (state: T["state"], actorId: string) => boolean;
58
- readonly timeout?: {
59
- readonly effect: (effect: T["effect"]) => TimeoutEffect | null;
60
- readonly command: (decisionId: string) => T["systemCommand"];
66
+ readonly scheduler?: {
67
+ readonly effect: (effect: T["effect"]) => ScheduledEffect | null;
68
+ readonly command: (id: string) => T["systemCommand"];
61
69
  };
62
70
  /** 保存後のbest-effort処理。結果はruntimeがSystem Commandとして再度dispatchする。 */
63
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.0.0-alpha.0`の利用手順です。
55
+ Node.js 22以降で、次を実行します。以下は`6.2.0`の利用手順です。
56
56
 
57
57
  ```sh
58
- npx def-game@6.0.0-alpha.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
@@ -133,6 +133,8 @@ Joinや設定変更は、その後のゲームCommandとして実装してくだ
133
133
  「部屋を作った人が所有者になる」というルールも、ライブラリではなくゲームが決めます。
134
134
 
135
135
  `project`では、自分の手札などActorに公開できる情報だけを返してください。
136
+ HTTPで部屋の概要や再読み込み用の情報を取得したい場合は、`room.getView(actor)`を使います。
137
+ 同じprojectが呼ばれるので、未参加者には要約、参加者には個別情報という出し分けもここで実装します。WSの接続資格は要求しません。
136
138
  `availableActions`は画面を作るための補助です。ボタンを非表示にしても不正なCommandは送れるため、操作可否は必ず`handleCommand`でも検証します。
137
139
 
138
140
  ## 3. ゲームと外部を接続する
@@ -193,12 +195,12 @@ webhookやマッチング処理から入力する場合は、アプリで認証
193
195
  | `game` | 必須 | 作成したGame Definitionを渡す |
194
196
  | `webSocket.parseCommand` | 必須 | unknownの入力を許可したActor Commandへ変換。拒否はnull |
195
197
  | `canConnect` | 必須 | StateとActor IDから接続・View配信の可否を判定 |
196
- | `timeout.effect`/`timeout.command` | 期限処理を使う場合 | Alarm用Effectの識別と、発火時のSystem Command作成 |
198
+ | `scheduler.effect`/`scheduler.command` | 予定イベントを使う場合 | Effectから論理予約への変換と、発火時のSystem Command作成 |
197
199
  | `executeEffect` | 外部Effectを出す場合 | 外部処理と、必要なら結果Commandの返却 |
198
200
  | `onError` | 独自の診断が必要な場合 | 失敗した区間の記録・通知 |
199
201
 
200
202
  `executeEffect`は型上は省略可能ですが、外部Effectを出すゲームでは対応するhandlerが必要です。
201
- Alarm用Effectはruntimeが状態と一緒に保存します。外部handlerから直接setAlarmする構成にはしません。
203
+ scheduler用Effectはruntimeが状態と一緒に保存します。外部handlerから直接setAlarmする構成にはしません。
202
204
 
203
205
  `src/room.ts`では、そのadapterを共通runtimeへ接続しています。
204
206
 
@@ -264,7 +266,7 @@ flowchart LR
264
266
  G -->|拒否| E["状態を変えずエラーを返す"]
265
267
  ```
266
268
 
267
- HTTP、WS、timeout、外部処理の完了がそれぞれ直接状態を書き換えると、どこにルールがあるのか追いにくくなります。
269
+ HTTP、WS、予定イベント、外部処理の完了がそれぞれ直接状態を書き換えると、どこにルールがあるのか追いにくくなります。
268
270
  DefGameでは初期状態の生成後、すべてのゲーム操作をCommandとして`handleCommand`へ集めます。
269
271
  同じ操作なら通信経路によらず同じルールを通り、変更理由をCommandとその処理から読めます。
270
272
  これは状態遷移の構造を揃える仕組みであり、Command履歴の永続保存や自動リプレイを提供するものではありません。
@@ -315,7 +317,7 @@ sequenceDiagram
315
317
  ただし、外部Effectはbest effortです。状態保存後、handlerが起動する前に停止すれば依頼が失われる可能性があります。
316
318
  外部処理の完了から結果Commandの保存までにも同様の隙間があります。
317
319
  ライブラリは永続outboxや自動再試行を提供せず、Effect失敗で保存済み状態を取り消しません。
318
- 結果が必須の機能では、アプリ側の復旧方法やゲーム側のtimeoutを設計してください。
320
+ 結果が必須の機能では、アプリ側の復旧方法やゲーム側の予定イベントを設計してください。
319
321
 
320
322
  ### 保存・Alarm・配信の手順を共通化する
321
323
 
@@ -323,36 +325,40 @@ sequenceDiagram
323
325
  flowchart TB
324
326
  Input["プレイヤーの操作"] --> G["handleCommand"]
325
327
  G --> Wait["安定した入力待ち状態<br/>誰の入力・どのdecision IDを待つか"]
326
- G --> Effect["Alarm用Effect<br/>decision ID・期限"]
328
+ G --> Effect["scheduler用Effect<br/>イベントID・期限"]
327
329
  subgraph TX["同じstorage transactionで確定"]
328
330
  State["入力待ち状態を保存"]
329
- Reservation["予約情報とCloudflare Alarmを設定"]
331
+ Reservations["N個の論理予約を保存"]
332
+ Alarm["最も早い期限を<br/>1つの物理Alarmへ設定"]
333
+ Reservations -->|min(deadline)| Alarm
330
334
  end
331
335
  Wait --> State
332
- Effect -->|adapterで識別| Reservation
336
+ Effect -->|adapterで識別| Reservations
333
337
  TX --> Saved["保存確定"]
334
338
  Saved --> View["View配信"]
335
339
  Saved --> Waiting["次の入力を待つ"]
336
340
  Waiting -->|期限前の操作| Next["Actor Command"]
337
- Waiting -->|Cloudflare Alarm発火| Timeout["System Command<br/>decision IDを付ける"]
341
+ Waiting -->|Cloudflare Alarm発火| Scheduled["System Command<br/>イベントIDを付ける"]
338
342
  Next --> Check["同じhandleCommand<br/>現在の入力待ちに有効か検証"]
339
- Timeout --> Check
343
+ Scheduled --> Check
340
344
  Check -->|有効| Transition["次の安定状態へ<br/>必要なら予約を取消・更新"]
341
345
  Check -->|古い入力等| Reject["現在状態を進めない"]
342
346
  ```
343
347
 
344
- 手番や選択待ちに期限を付けるとき、ゲームは「期限に達したら何をするか」を実装します。
345
- 予約の保存とCloudflareネイティブのAlarmへの接続はruntimeに任せられます。
346
- タイムアウトは任意の機能ですが、プレイヤーの離席や切断で進行が止まるゲームに役立ちます。
348
+ 手番や選択待ち、inactivity、再接続猶予などに期限を付けるとき、ゲームは「期限に達したら何をするか」を実装します。
349
+ 複数の論理予約の保存とCloudflareネイティブの1つのAlarmへの接続はruntimeに任せられます。
350
+ runtimeはイベントIDの意味を解釈せず、最も早い期限だけを物理Alarmへ設定します。
347
351
  図のtransactionは保存処理だけを囲んでいます。将来の発火・Command実行や、取り消せないView配信は含みません。
348
352
 
349
- 複数の入力が届いても、runtimeが状態取得から更新を直列化し、状態とAlarm予約を同じtransactionで保存します。
353
+ 複数の入力が届いても、runtimeが状態取得から更新を直列化し、状態と論理予約を同じtransactionで保存します。
350
354
  これにより、ゲームごとに同じ排他制御や保存手順を実装する負担を減らします。
351
355
  保存が確定してからViewを配信し、外部Effectは直列化区間を出て実行します。
352
356
  外部処理を待っている間にも次のCommandを処理し、結果が戻れば最新状態に対して検証します。
353
357
 
354
- Alarmの予約が状態と一緒に確定することと、将来のAlarm発火が一度だけ処理されることは別です。
355
- ゲーム側でもdecision IDと現在状態を確認し、古い入力を適用しないようにします。
358
+ Alarm発火時は期限に達した予約を1件ずつ通常のCommandとして処理し、そのたびに最新のStateと予約を読み直します。
359
+ 先のCommandがゲームを終了して次の予約を取り消せば、その予約は処理されません。
360
+ 論理予約が状態と一緒に確定することと、将来のAlarm発火が一度だけ処理されることは別です。
361
+ ゲーム側でもイベントIDと現在状態を確認し、古い入力を適用しないようにします。
356
362
 
357
363
  ### ゲームの参加状態を、通信の寿命から切り離す
358
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
 
@@ -172,7 +172,7 @@ export type TransitionResult<State, Effect, Error> =
172
172
  `availableActions`はViewに含める設計上の契約ですが、型の制約で自動的に必須化されてはいません。
173
173
  その表示は認可の代わりにならないため、Command実行時にも検証します。
174
174
 
175
- runtimeは接続時とCommand保存後の配信で呼びます。同じActorの複数接続などで何度呼ばれてもStateを変更してはいけません。
175
+ runtimeはHTTP等からの`getView`、接続時、Command保存後の配信で呼びます。同じActorの複数接続などで何度呼ばれてもStateを変更してはいけません。
176
176
  配信中に例外が出ても保存済みCommandは取り消しません。該当接続は閉じられ、診断対象になります。
177
177
 
178
178
  <a id="adapter-properties"></a>
@@ -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
@@ -313,8 +323,9 @@ State・Command・Effect・元の例外本文は引数に含めません。未
313
323
  interface VerifiedActor { readonly actorId: string }
314
324
 
315
325
  // getRoom(namespace, durableObjectId)が返す参照
316
- interface RoomClient<ActorCommand, Error> {
326
+ interface RoomClient<ActorCommand, Error, View = unknown> {
317
327
  create(): Promise<CreateRoomResult>;
328
+ getView(actor: VerifiedActor): Promise<GetViewResult<View>>;
318
329
  dispatchActor(actor: VerifiedActor, command: ActorCommand): Promise<CommandResult<Error>>;
319
330
  connect(actor: VerifiedActor): Promise<Response>;
320
331
  }
@@ -322,6 +333,34 @@ interface RoomClient<ActorCommand, Error> {
322
333
  // { dispatchSystem(command: SystemCommand): Promise<CommandResult<Error>> }
323
334
  ```
324
335
 
336
+ ### getView(actor):接続せずにViewを取得する
337
+
338
+ ```ts
339
+ const room = getRoom(env.ROOMS, id);
340
+ const result = await room.getView(verifiedActor);
341
+ // 公開HTTPのレスポンスやステータスはアプリで決める
342
+ ```
343
+
344
+ ```ts
345
+ type GetViewResult<View> =
346
+ | { readonly ok: true; readonly view: View }
347
+ | { readonly ok: false; readonly error: RuntimeError };
348
+ ```
349
+
350
+ runtimeが保存済みStateを読み、既存の`game.project(state, actor.actorId)`を呼んで返します。
351
+ ゲーム側に新しい関数の実装は必要ありません。View型はDOのadapterから推論されます。
352
+ 状態変更・Command実行・Effect・WS接続・他の接続への配信は行いません。
353
+
354
+ `canConnect`は呼びません。認証済みの未参加者にも、projectが部屋の概要等を返せます。
355
+ 参加者・観戦者・未参加者で何を見せるかはprojectで判断し、非公開Stateを返さないでください。
356
+ 未認証の公開閲覧を提供するAPIではなく、アプリは呼び出し前にActorを認証します。
357
+
358
+ 未作成はRoomNotFound、不正ActorはInvalidRequest、読み取り・projectの例外はInternalErrorです。
359
+ 内部例外はonErrorのview区間へ通知し、保存済み状態を変更しません。
360
+ 返るのは読み取り時点のViewであり、その後の操作が同じ状態で受理される保証はありません。
361
+
362
+ ### 作成・操作・接続
363
+
325
364
  `create()`は初期状態のみを保存し、二重作成を拒否します。JoinはゲームCommandです。
326
365
  `connect()`成功時は101のWS応答を返します。未作成404、接続資格なし403、内部例外500等になります。
327
366
  Actorが不正な場合は呼び出し側で例外になることもあります。
@@ -414,10 +453,10 @@ requestIdは応答の対応付け用で、永続的な重複排除には使い
414
453
 
415
454
  ### 始め方
416
455
 
417
- Node.js 22以降を使用します。先行公開版`6.0.0-alpha.0`を指定して生成します。
456
+ Node.js 22以降を使用します。`6.2.0`を指定して生成します。
418
457
 
419
458
  ```sh
420
- npx def-game@6.0.0-alpha.0 init-worker --directory my-game --name my-game
459
+ npx def-game@6.2.0 init-worker --directory my-game --name my-game
421
460
  cd my-game
422
461
  npm install
423
462
  npm run dev
@@ -425,7 +464,7 @@ npm run dev
425
464
 
426
465
  開発版をこのリポジトリから試す場合は、リポジトリで`npm pack`を実行します。
427
466
  `node bin/def-game.cjs init-worker --directory /path/to/my-game --name my-game`で生成し、生成先で
428
- `npm install /absolute/path/to/def-game-6.0.0-alpha.0.tgz`を実行してください。
467
+ `npm install /absolute/path/to/def-game-6.2.0.tgz`を実行してください。
429
468
  その後は`npm run typecheck`、`npm run build`、`npm run dev`を利用できます。buildはWranglerのdry-runで、公開しません。
430
469
 
431
470
  生成先は新しいディレクトリに限定します。既存ディレクトリは空でも拒否します。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "def-game",
3
- "version": "6.0.0-alpha.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
- 未公開alpha版では、`npm install`の代わりに`npm install /absolute/path/to/def-game-6.0.0-alpha.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.0.0-alpha.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 });