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 +2 -2
- package/dist/worker/cloudflare/room-client.d.ts +10 -8
- package/dist/worker/cloudflare/room-client.d.ts.map +1 -1
- package/dist/worker/cloudflare/room-client.js +1 -0
- package/dist/worker/cloudflare/session-runtime.d.ts +3 -1
- package/dist/worker/cloudflare/session-runtime.d.ts.map +1 -1
- package/dist/worker/cloudflare/session-runtime.js +74 -32
- package/dist/worker/cloudflare/types.d.ts +15 -7
- package/dist/worker/cloudflare/types.d.ts.map +1 -1
- package/docs/design-philosophy.md +26 -20
- package/docs/runtime-api.md +65 -26
- 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,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;
|
|
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,
|
|
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入口が担当する。
|
|
@@ -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
|
|
47
|
-
|
|
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.
|
|
54
|
-
throw new Error("
|
|
55
|
-
command = this.adapter.
|
|
56
|
-
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;
|
|
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
|
|
90
|
+
const scheduled = [];
|
|
71
91
|
for (const effect of result.effects) {
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
92
|
+
const scheduledEffect = this.adapter.scheduler?.effect(effect) ?? null;
|
|
93
|
+
if (scheduledEffect === null)
|
|
74
94
|
external.push(effect);
|
|
75
95
|
else {
|
|
76
|
-
if (!
|
|
77
|
-
|| (
|
|
78
|
-
throw new Error("Invalid
|
|
79
|
-
|
|
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
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
await txn.
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/**
|
|
37
|
-
export type
|
|
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
|
|
47
|
+
readonly id: string;
|
|
40
48
|
readonly deadline: number;
|
|
41
49
|
} | {
|
|
42
50
|
readonly type: "cancel";
|
|
43
|
-
readonly
|
|
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
|
|
59
|
-
readonly effect: (effect: T["effect"]) =>
|
|
60
|
-
readonly command: (
|
|
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":"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,
|
|
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
|
|
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
|
|
@@ -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
|
-
| `
|
|
198
|
+
| `scheduler.effect`/`scheduler.command` | 予定イベントを使う場合 | Effectから論理予約への変換と、発火時のSystem Command作成 |
|
|
197
199
|
| `executeEffect` | 外部Effectを出す場合 | 外部処理と、必要なら結果Commandの返却 |
|
|
198
200
|
| `onError` | 独自の診断が必要な場合 | 失敗した区間の記録・通知 |
|
|
199
201
|
|
|
200
202
|
`executeEffect`は型上は省略可能ですが、外部Effectを出すゲームでは対応するhandlerが必要です。
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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["
|
|
328
|
+
G --> Effect["scheduler用Effect<br/>イベントID・期限"]
|
|
327
329
|
subgraph TX["同じstorage transactionで確定"]
|
|
328
330
|
State["入力待ち状態を保存"]
|
|
329
|
-
|
|
331
|
+
Reservations["N個の論理予約を保存"]
|
|
332
|
+
Alarm["最も早い期限を<br/>1つの物理Alarmへ設定"]
|
|
333
|
+
Reservations -->|min(deadline)| Alarm
|
|
330
334
|
end
|
|
331
335
|
Wait --> State
|
|
332
|
-
Effect -->|adapterで識別|
|
|
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発火|
|
|
341
|
+
Waiting -->|Cloudflare Alarm発火| Scheduled["System Command<br/>イベントIDを付ける"]
|
|
338
342
|
Next --> Check["同じhandleCommand<br/>現在の入力待ちに有効か検証"]
|
|
339
|
-
|
|
343
|
+
Scheduled --> Check
|
|
340
344
|
Check -->|有効| Transition["次の安定状態へ<br/>必要なら予約を取消・更新"]
|
|
341
345
|
Check -->|古い入力等| Reject["現在状態を進めない"]
|
|
342
346
|
```
|
|
343
347
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
348
|
+
手番や選択待ち、inactivity、再接続猶予などに期限を付けるとき、ゲームは「期限に達したら何をするか」を実装します。
|
|
349
|
+
複数の論理予約の保存とCloudflareネイティブの1つのAlarmへの接続はruntimeに任せられます。
|
|
350
|
+
runtimeはイベントIDの意味を解釈せず、最も早い期限だけを物理Alarmへ設定します。
|
|
347
351
|
図のtransactionは保存処理だけを囲んでいます。将来の発火・Command実行や、取り消せないView配信は含みません。
|
|
348
352
|
|
|
349
|
-
複数の入力が届いても、runtime
|
|
353
|
+
複数の入力が届いても、runtimeが状態取得から更新を直列化し、状態と論理予約を同じtransactionで保存します。
|
|
350
354
|
これにより、ゲームごとに同じ排他制御や保存手順を実装する負担を減らします。
|
|
351
355
|
保存が確定してからViewを配信し、外部Effectは直列化区間を出て実行します。
|
|
352
356
|
外部処理を待っている間にも次のCommandを処理し、結果が戻れば最新状態に対して検証します。
|
|
353
357
|
|
|
354
|
-
Alarm
|
|
355
|
-
|
|
358
|
+
Alarm発火時は期限に達した予約を1件ずつ通常のCommandとして処理し、そのたびに最新のStateと予約を読み直します。
|
|
359
|
+
先のCommandがゲームを終了して次の予約を取り消せば、その予約は処理されません。
|
|
360
|
+
論理予約が状態と一緒に確定することと、将来のAlarm発火が一度だけ処理されることは別です。
|
|
361
|
+
ゲーム側でもイベントIDと現在状態を確認し、古い入力を適用しないようにします。
|
|
356
362
|
|
|
357
363
|
### ゲームの参加状態を、通信の寿命から切り離す
|
|
358
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
|
|
|
@@ -172,7 +172,7 @@ export type TransitionResult<State, Effect, Error> =
|
|
|
172
172
|
`availableActions`はViewに含める設計上の契約ですが、型の制約で自動的に必須化されてはいません。
|
|
173
173
|
その表示は認可の代わりにならないため、Command実行時にも検証します。
|
|
174
174
|
|
|
175
|
-
runtime
|
|
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
|
-
###
|
|
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
|
|
@@ -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
|
|
456
|
+
Node.js 22以降を使用します。`6.2.0`を指定して生成します。
|
|
418
457
|
|
|
419
458
|
```sh
|
|
420
|
-
npx def-game@6.
|
|
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.
|
|
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
|
@@ -12,7 +12,7 @@ npm install
|
|
|
12
12
|
npm run dev
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
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 });
|