def-game 5.0.1 → 5.1.0-timeout.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
|
@@ -39,7 +39,7 @@ V5 は破壊的変更です。GameRule、GameEngine、TaskQueue に関する型
|
|
|
39
39
|
|
|
40
40
|
## Worker generator(5.0.1)
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Hono Worker、SQLite-backed Durable Object、waki.work JWT 認証、Hibernation WebSocket、保存・配信処理を生成します。GameDefinition の契約変更はありません。標準構成は timeout / Effect 実行なしです。開発版では下記の timeout オプションを追加しています。
|
|
43
43
|
|
|
44
44
|
server package に `def-game.worker.json` を作ります。全パスはこの設定ファイルのディレクトリ基準です。
|
|
45
45
|
|
|
@@ -62,7 +62,9 @@ npx def-game generate-worker --config def-game.worker.json
|
|
|
62
62
|
npx def-game generate-worker --config def-game.worker.json --check
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
生成対象は `outputDir` 内の `index.ts`、`session.ts`、`auth.ts`、`env.ts`、`runtime/parse.ts`、`runtime/game-adapter.ts` と、`entry`、`wranglerConfig` の計8ファイルです。Hono / jose は利用側の runtime dependency で、generator 自体に実行時依存はありません。
|
|
65
|
+
生成対象は `outputDir` 内の `index.ts`、`session.ts`、`auth.ts`、`env.ts`、`runtime/parse.ts`、`runtime/game-adapter.ts` と、`entry`、`wranglerConfig` の計8ファイルです。Hono / jose は利用側の runtime dependency で、generator 自体に実行時依存はありません。
|
|
66
|
+
|
|
67
|
+
生成コードは Node.js 24.20.0、TypeScript 5.6.3、Hono 4.13.7、jose 6.2.12、Wrangler 4.131.1、`@cloudflare/workers-types` 5.20260911.1 の環境で、ビルド・型チェック・HTTP / WebSocket / Durable Object の通信テストを確認しています。
|
|
66
68
|
|
|
67
69
|
ゲーム側は既存ファイルとして `adapter` を用意し、次を export します。このファイル、domain、shared は生成・上書きしません。
|
|
68
70
|
|
|
@@ -94,3 +96,26 @@ npm pack
|
|
|
94
96
|
npm pack / npm publish の前に prepack で型チェック・ビルド・テストが実行されます。build は dist を作り直すため、削除した旧 API の生成物が混入しません。公開対象は dist、bin、templates、package.json、README、LICENSE です。
|
|
95
97
|
|
|
96
98
|
main にレビュー済みの変更を反映した後、公開する version と認証アカウントを確認し、検証済みのパッケージを npm publish で公開します。
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
## Decision timeout(5.1.0-timeout.0 開発版)
|
|
102
|
+
|
|
103
|
+
生成設定に `"timeout": true` を追加すると、DO Alarm と system command 配送を生成します。未指定または false なら従来の構成です。開発版は npm 未公開です。
|
|
104
|
+
|
|
105
|
+
`GameAdapter<State, Command, View, Error, Effect>` に次の任意の接続点を設定します。
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
timeout: {
|
|
109
|
+
effect: (effect) => /* { type: "schedule", decisionId: string, deadline: number }
|
|
110
|
+
または { type: "cancel", decisionId: string } */,
|
|
111
|
+
command: (decisionId) => /* ゲーム固有の system command */,
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
ゲームは新しい decision で再利用しない ID と期限を発行します。同じ decision 内の部分完了では Effect を出しません。default action・pending actors・bot policy はゲーム側に残します。runtime は State を解釈せず、1つの現在予約を保存します。独立した複数の同時 decision のスケジューラーではありません。
|
|
116
|
+
|
|
117
|
+
状態・予約・Alarm は同じ storage transaction で確定します。Alarm は保存済み予約から system command を生成し、古い発火が新しい予約を期限前に処理しないよう再予約します。domain も ID・期限を検証してください。期限を迎えた予約は成功した遷移と同じ transaction で消費し、次の予約があれば置き換えます。失敗は例外として伝播し Cloudflare の有限回の再試行に委ねます。無制限の再試行・外部 Effect の配送は含みません。
|
|
118
|
+
|
|
119
|
+
このオプションで扱う Effect は timeout の予約・解除のみです。外部通知と異なり、Alarm は状態と原子的に保存するローカルな永続化処理です。外部サービス呼び出しは transaction 内に追加しないでください。
|
|
120
|
+
|
|
121
|
+
実行環境の検証: Node.js 24、TypeScript 5.6、Hono 4.13、jose 6.2、Wrangler 4.131、Cloudflare Vitest plugin 1.1。標準構成・任意構成の生成一致を generator テストで確認しています。
|
package/bin/def-game.cjs
CHANGED
|
@@ -11,7 +11,7 @@ Usage: def-game generate-worker --config <file.json> [--force | --check]
|
|
|
11
11
|
Generate a Hono Worker, SQLite Durable Object, waki.work authentication and Wrangler config.
|
|
12
12
|
--force Replace differing output files (never edits the game adapter).
|
|
13
13
|
--check Verify generated files match without writing anything.
|
|
14
|
-
Node.js 20+ is required.
|
|
14
|
+
Node.js 20+ is required. Set timeout: true in the config to generate decision timeout scheduling.`;
|
|
15
15
|
|
|
16
16
|
function relativeImport(from, target) {
|
|
17
17
|
let value = path.relative(path.dirname(from), target).split(path.sep).join('/').replace(/\.ts$/, '.js');
|
|
@@ -39,7 +39,8 @@ function main(args) {
|
|
|
39
39
|
const root = path.dirname(configPath);
|
|
40
40
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
41
41
|
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Config must be an object');
|
|
42
|
-
const allowed = new Set(['outputDir', 'adapter', 'entry', 'wranglerConfig', 'name', 'assets', 'compatibilityDate']);
|
|
42
|
+
const allowed = new Set(['outputDir', 'adapter', 'entry', 'wranglerConfig', 'name', 'assets', 'compatibilityDate', 'timeout']);
|
|
43
|
+
if (config.timeout !== undefined && typeof config.timeout !== 'boolean') throw new Error('timeout must be boolean');
|
|
43
44
|
for (const key of Object.keys(config)) if (!allowed.has(key)) throw new Error(`Unknown config key: ${key}`);
|
|
44
45
|
const required = (key) => {
|
|
45
46
|
if (typeof config[key] !== 'string' || !config[key].trim() || config[key].includes('\0')) throw new Error(`Invalid config: ${key}`);
|
|
@@ -83,7 +84,8 @@ function main(args) {
|
|
|
83
84
|
};
|
|
84
85
|
for (const file of ['index.ts', 'session.ts', 'auth.ts', 'env.ts', 'runtime/parse.ts', 'runtime/game-adapter.ts']) {
|
|
85
86
|
const target = path.join(outputDir, file);
|
|
86
|
-
const
|
|
87
|
+
const template = config.timeout && ['session.ts', 'runtime/game-adapter.ts'].includes(file) ? path.join('timeout', file) : file;
|
|
88
|
+
const source = fs.readFileSync(path.join(__dirname, '../templates/worker', template), 'utf8');
|
|
87
89
|
add(target, source.replaceAll('__ADAPTER_IMPORT__', relativeImport(target, adapter)));
|
|
88
90
|
}
|
|
89
91
|
add(entry, `export { default, SessionDurableObject } from ${relativeImport(entry, path.join(outputDir, 'index.ts'))};\n`);
|
|
@@ -11,7 +11,8 @@ export type CommandContext<ActorId> = {
|
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
13
|
* 成功時は次の安定状態と副作用の宣言、失敗時はゲーム上の拒否理由を返す。
|
|
14
|
-
*
|
|
14
|
+
* 実行側は成功した状態を保存してから外部 Effect を処理する。
|
|
15
|
+
* 同じストレージ内の Alarm 予約などは、状態と原子的に保存できる。
|
|
15
16
|
*/
|
|
16
17
|
export type TransitionResult<State, Effect, Error> = {
|
|
17
18
|
readonly ok: true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"game-definition.d.ts","sourceRoot":"","sources":["../src/game-definition.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,cAAc,CAAC,OAAO,IAC5B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEpC
|
|
1
|
+
{"version":3,"file":"game-definition.d.ts","sourceRoot":"","sources":["../src/game-definition.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,cAAc,CAAC,OAAO,IAC5B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACvD;IAAE,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAEpC;;;;GAIG;AACH,MAAM,MAAM,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,IAC3C;IACE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAClB,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACvC,GACC;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;AAEpD;;;GAGG;AACH,MAAM,WAAW,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK;IACxE,0CAA0C;IAC1C,kBAAkB,IAAI,KAAK,CAAC;IAE5B;;;;OAIG;IACH,aAAa,CACT,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,OAAO,CAAC,GACjC,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAE1C;;;OAGG;IACH,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;CACjD"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GameDefinition } from "def-game";
|
|
2
|
+
|
|
3
|
+
export type TimeoutEffect =
|
|
4
|
+
| { readonly type: "schedule"; readonly decisionId: string; readonly deadline: number }
|
|
5
|
+
| { readonly type: "cancel"; readonly decisionId: string };
|
|
6
|
+
|
|
7
|
+
/** Runtime never interprets State. The adapter translates game effects and timeout commands. */
|
|
8
|
+
export interface GameAdapter<State, Command, View, Error, Effect = never> {
|
|
9
|
+
readonly game: GameDefinition<State, Command, string, View, Effect, Error>;
|
|
10
|
+
readonly parseCreate: (input: unknown) => Command | null;
|
|
11
|
+
readonly parseJoin: (input: unknown) => Command | null;
|
|
12
|
+
readonly parseCommand: (input: unknown) => Command | null;
|
|
13
|
+
readonly canConnect: (state: State, actorId: string) => boolean;
|
|
14
|
+
readonly timeout?: {
|
|
15
|
+
readonly effect: (effect: Effect) => TimeoutEffect;
|
|
16
|
+
readonly command: (decisionId: string) => Command;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import { gameAdapter, type State, type ServerMessage, type ProtocolError } from __ADAPTER_IMPORT__;
|
|
3
|
+
import type { Env } from "./env.js";
|
|
4
|
+
import { isRecord, parseCommandRequest } from "./runtime/parse.js";
|
|
5
|
+
|
|
6
|
+
const STATE_KEY = "game-state";
|
|
7
|
+
const TIMEOUT_KEY = "decision-timeout";
|
|
8
|
+
type Reservation = { decisionId: string; deadline: number };
|
|
9
|
+
type Success = Extract<ReturnType<typeof gameAdapter.game.handleCommand>, { ok: true }>;
|
|
10
|
+
|
|
11
|
+
/** 生成対象の Session runtime。ゲーム固有の判断は gameAdapter だけを通して呼ぶ。 */
|
|
12
|
+
export class SessionDurableObject extends DurableObject<Env> {
|
|
13
|
+
// State の独自キャッシュを持たない。再起動時にも Storage と attachment だけで復元する。
|
|
14
|
+
async fetch(request: Request): Promise<Response> {
|
|
15
|
+
return this.ctx.blockConcurrencyWhile(async () => {
|
|
16
|
+
const actorId = request.headers.get("x-actor-id");
|
|
17
|
+
const fail = (code: ProtocolError["code"], status: number) => Response.json({ ok: false, error: { code } }, { status });
|
|
18
|
+
if (!actorId) return fail("AuthenticationRequired", 401);
|
|
19
|
+
const path = new URL(request.url).pathname;
|
|
20
|
+
const stored = await this.ctx.storage.get<State>(STATE_KEY);
|
|
21
|
+
if (path === "/connect" && request.method === "GET") {
|
|
22
|
+
if (stored === undefined) return fail("SessionNotFound", 404);
|
|
23
|
+
if (!gameAdapter.canConnect(stored, actorId)) return fail("NotSessionMember", 403);
|
|
24
|
+
const viewState = gameAdapter.game.project(stored, actorId);
|
|
25
|
+
const pair = new WebSocketPair();
|
|
26
|
+
pair[1].serializeAttachment({ actorId });
|
|
27
|
+
this.ctx.acceptWebSocket(pair[1]);
|
|
28
|
+
this.send(pair[1], { type: "ViewStateEvent", viewState });
|
|
29
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
30
|
+
}
|
|
31
|
+
if (request.method !== "POST" || (path !== "/create" && path !== "/join")) return fail("InvalidRequest", 400);
|
|
32
|
+
if (path === "/create" && stored !== undefined) return fail("InvalidRequest", 409);
|
|
33
|
+
if (path === "/join" && stored === undefined) return fail("SessionNotFound", 404);
|
|
34
|
+
let body: unknown;
|
|
35
|
+
try { body = await request.json(); } catch { return fail("InvalidRequest", 400); }
|
|
36
|
+
const command = path === "/create" ? gameAdapter.parseCreate(body) : gameAdapter.parseJoin(body);
|
|
37
|
+
if (command === null) return fail("InvalidRequest", 400);
|
|
38
|
+
const result = gameAdapter.game.handleCommand(stored === undefined ? gameAdapter.game.createInitialState() : stored, command, { origin: "actor", actorId });
|
|
39
|
+
if (!result.ok) return Response.json(result, { status: 409 });
|
|
40
|
+
// 作成と作成者の参加は、この1回の保存で確定する。
|
|
41
|
+
if (result.state !== stored || result.effects.length > 0) {
|
|
42
|
+
try { await this.commit(result); } catch { return fail("InternalError", 500); }
|
|
43
|
+
this.broadcast(result.state);
|
|
44
|
+
}
|
|
45
|
+
return Response.json({ ok: true });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async webSocketMessage(socket: WebSocket, raw: string | ArrayBuffer): Promise<void> {
|
|
50
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
51
|
+
const parsed = parseCommandRequest(raw);
|
|
52
|
+
const command = parsed ? gameAdapter.parseCommand(parsed.command) : null;
|
|
53
|
+
if (!parsed || command === null) {
|
|
54
|
+
this.send(socket, { type: "ProtocolErrorEvent", error: { code: "InvalidRequest" } });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const attachment: unknown = socket.deserializeAttachment();
|
|
59
|
+
const state = await this.ctx.storage.get<State>(STATE_KEY);
|
|
60
|
+
if (state === undefined || !isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) {
|
|
61
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "NotSessionMember" } });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const result = gameAdapter.game.handleCommand(state, command, { origin: "actor", actorId: attachment.actorId });
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: result.error });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
await this.commit(result);
|
|
70
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: true });
|
|
71
|
+
this.broadcast(result.state);
|
|
72
|
+
} catch {
|
|
73
|
+
console.error("Session command failed");
|
|
74
|
+
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "InternalError" } });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Commit state, reservation and alarm together. No external effects inside the transaction. */
|
|
80
|
+
private async commit(result: Success, consumedDecisionId?: string): Promise<void> {
|
|
81
|
+
const effects = result.effects.map(effect => {
|
|
82
|
+
if (!gameAdapter.timeout) throw new Error("Timeout adapter required for effects");
|
|
83
|
+
return gameAdapter.timeout.effect(effect);
|
|
84
|
+
});
|
|
85
|
+
await this.ctx.storage.transaction(async txn => {
|
|
86
|
+
await txn.put(STATE_KEY, result.state);
|
|
87
|
+
if (consumedDecisionId !== undefined) {
|
|
88
|
+
const current = await txn.get<Reservation>(TIMEOUT_KEY);
|
|
89
|
+
if (current?.decisionId === consumedDecisionId) {
|
|
90
|
+
await txn.delete(TIMEOUT_KEY);
|
|
91
|
+
await txn.deleteAlarm();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const effect of effects) {
|
|
95
|
+
if (effect.type === "schedule") {
|
|
96
|
+
if (!effect.decisionId || !Number.isFinite(effect.deadline)) throw new Error("Invalid timeout reservation");
|
|
97
|
+
await txn.put(TIMEOUT_KEY, { decisionId: effect.decisionId, deadline: effect.deadline });
|
|
98
|
+
await txn.setAlarm(effect.deadline);
|
|
99
|
+
} else {
|
|
100
|
+
const current = await txn.get<Reservation>(TIMEOUT_KEY);
|
|
101
|
+
if (current?.decisionId === effect.decisionId) {
|
|
102
|
+
await txn.delete(TIMEOUT_KEY);
|
|
103
|
+
await txn.deleteAlarm();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async alarm(): Promise<void> {
|
|
111
|
+
await this.ctx.blockConcurrencyWhile(async () => {
|
|
112
|
+
const reservation = await this.ctx.storage.get<Reservation>(TIMEOUT_KEY);
|
|
113
|
+
if (!reservation) return;
|
|
114
|
+
// An old alarm can wake after a new reservation replaced it. Never expire that decision early.
|
|
115
|
+
if (Date.now() < reservation.deadline) {
|
|
116
|
+
await this.ctx.storage.setAlarm(reservation.deadline);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const state = await this.ctx.storage.get<State>(STATE_KEY);
|
|
120
|
+
if (state === undefined || !gameAdapter.timeout) throw new Error("Missing timeout state or adapter");
|
|
121
|
+
const result = gameAdapter.game.handleCommand(state, gameAdapter.timeout.command(reservation.decisionId), { origin: "system" });
|
|
122
|
+
if (!result.ok) throw new Error("Timeout command rejected");
|
|
123
|
+
// Failed commits throw so Cloudflare can retry. Delivery failure never rolls back committed state.
|
|
124
|
+
await this.commit(result, reservation.decisionId);
|
|
125
|
+
this.broadcast(result.state);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private broadcast(state: State): void {
|
|
130
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
131
|
+
try {
|
|
132
|
+
const attachment: unknown = socket.deserializeAttachment();
|
|
133
|
+
if (!isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) continue;
|
|
134
|
+
this.send(socket, { type: "ViewStateEvent", viewState: gameAdapter.game.project(state, attachment.actorId) });
|
|
135
|
+
} catch {
|
|
136
|
+
console.error("Session view delivery failed");
|
|
137
|
+
this.close(socket);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private send(socket: WebSocket, message: ServerMessage): void {
|
|
143
|
+
try { socket.send(JSON.stringify(message)); } catch { this.close(socket); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private close(socket: WebSocket): void {
|
|
147
|
+
try { socket.close(1011, "Delivery failed"); } catch { /* 切断済み。確定済みの保存結果へ影響させない。 */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
webSocketClose(socket: WebSocket, code: number, reason: string): void { socket.close(code === 1005 || code === 1006 || code === 1015 ? 1000 : code, reason); }
|
|
151
|
+
webSocketError(socket: WebSocket): void { socket.close(1011, "Connection error"); }
|
|
152
|
+
}
|