def-game 5.1.0-timeout.0 → 6.1.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 +11 -114
- package/bin/def-game.cjs +8 -124
- package/bin/init-worker.cjs +48 -0
- package/dist/worker/cloudflare/index.d.ts +5 -0
- package/dist/worker/cloudflare/index.d.ts.map +1 -0
- package/dist/worker/cloudflare/index.js +2 -0
- package/dist/worker/cloudflare/protocol.d.ts +11 -0
- package/dist/worker/cloudflare/protocol.d.ts.map +1 -0
- package/dist/worker/cloudflare/protocol.js +24 -0
- package/dist/worker/cloudflare/room-client.d.ts +28 -0
- package/dist/worker/cloudflare/room-client.d.ts.map +1 -0
- package/dist/worker/cloudflare/room-client.js +21 -0
- package/dist/worker/cloudflare/session-runtime.d.ts +27 -0
- package/dist/worker/cloudflare/session-runtime.d.ts.map +1 -0
- package/dist/worker/cloudflare/session-runtime.js +279 -0
- package/dist/worker/cloudflare/types.d.ts +99 -0
- package/dist/worker/cloudflare/types.d.ts.map +1 -0
- package/dist/worker/cloudflare/types.js +1 -0
- package/dist/worker/game-definition.d.ts +44 -0
- package/dist/worker/game-definition.d.ts.map +1 -0
- package/dist/worker/game-definition.js +1 -0
- package/dist/worker/package.json +1 -0
- package/docs/design-philosophy.md +410 -0
- package/docs/runtime-api.md +490 -0
- package/package.json +33 -7
- package/templates/starter/AGENTS.md +15 -0
- package/templates/starter/README.md +18 -0
- package/templates/starter/gitignore +5 -0
- package/templates/starter/package.json +15 -0
- package/templates/starter/public/app.js +93 -0
- package/templates/starter/public/index.html +37 -0
- package/templates/starter/scripts/dev-auth.mjs +40 -0
- package/templates/starter/scripts/dev.mjs +16 -0
- package/templates/starter/src/env.ts +10 -0
- package/templates/starter/src/external/decks.ts +15 -0
- package/templates/starter/src/game/definition.ts +79 -0
- package/templates/starter/src/game/types.ts +44 -0
- package/templates/starter/src/game-adapter.ts +29 -0
- package/templates/starter/src/index.ts +88 -0
- package/templates/starter/src/room.ts +9 -0
- package/templates/starter/tsconfig.json +8 -0
- package/templates/starter/wrangler.jsonc +14 -0
- package/templates/worker/env.ts +0 -9
- package/templates/worker/index.ts +0 -84
- package/templates/worker/runtime/game-adapter.ts +0 -15
- package/templates/worker/runtime/parse.ts +0 -14
- package/templates/worker/session.ts +0 -100
- package/templates/worker/timeout/runtime/game-adapter.ts +0 -18
- package/templates/worker/timeout/session.ts +0 -152
- /package/templates/{worker → starter/src}/auth.ts +0 -0
|
@@ -1,100 +0,0 @@
|
|
|
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
|
-
|
|
8
|
-
/** 生成対象の Session runtime。ゲーム固有の判断は gameAdapter だけを通して呼ぶ。 */
|
|
9
|
-
export class SessionDurableObject extends DurableObject<Env> {
|
|
10
|
-
// State の独自キャッシュを持たない。再起動時にも Storage と attachment だけで復元する。
|
|
11
|
-
async fetch(request: Request): Promise<Response> {
|
|
12
|
-
return this.ctx.blockConcurrencyWhile(async () => {
|
|
13
|
-
const actorId = request.headers.get("x-actor-id");
|
|
14
|
-
const fail = (code: ProtocolError["code"], status: number) => Response.json({ ok: false, error: { code } }, { status });
|
|
15
|
-
if (!actorId) return fail("AuthenticationRequired", 401);
|
|
16
|
-
const path = new URL(request.url).pathname;
|
|
17
|
-
const stored = await this.ctx.storage.get<State>(STATE_KEY);
|
|
18
|
-
if (path === "/connect" && request.method === "GET") {
|
|
19
|
-
if (stored === undefined) return fail("SessionNotFound", 404);
|
|
20
|
-
if (!gameAdapter.canConnect(stored, actorId)) return fail("NotSessionMember", 403);
|
|
21
|
-
const viewState = gameAdapter.game.project(stored, actorId);
|
|
22
|
-
const pair = new WebSocketPair();
|
|
23
|
-
pair[1].serializeAttachment({ actorId });
|
|
24
|
-
this.ctx.acceptWebSocket(pair[1]);
|
|
25
|
-
this.send(pair[1], { type: "ViewStateEvent", viewState });
|
|
26
|
-
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
27
|
-
}
|
|
28
|
-
if (request.method !== "POST" || (path !== "/create" && path !== "/join")) return fail("InvalidRequest", 400);
|
|
29
|
-
if (path === "/create" && stored !== undefined) return fail("InvalidRequest", 409);
|
|
30
|
-
if (path === "/join" && stored === undefined) return fail("SessionNotFound", 404);
|
|
31
|
-
let body: unknown;
|
|
32
|
-
try { body = await request.json(); } catch { return fail("InvalidRequest", 400); }
|
|
33
|
-
const command = path === "/create" ? gameAdapter.parseCreate(body) : gameAdapter.parseJoin(body);
|
|
34
|
-
if (command === null) return fail("InvalidRequest", 400);
|
|
35
|
-
const result = gameAdapter.game.handleCommand(stored === undefined ? gameAdapter.game.createInitialState() : stored, command, { origin: "actor", actorId });
|
|
36
|
-
if (!result.ok) return Response.json(result, { status: 409 });
|
|
37
|
-
// 作成と作成者の参加は、この1回の保存で確定する。
|
|
38
|
-
if (result.state !== stored) {
|
|
39
|
-
await this.ctx.storage.put(STATE_KEY, result.state);
|
|
40
|
-
this.broadcast(result.state);
|
|
41
|
-
}
|
|
42
|
-
return Response.json({ ok: true });
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async webSocketMessage(socket: WebSocket, raw: string | ArrayBuffer): Promise<void> {
|
|
47
|
-
await this.ctx.blockConcurrencyWhile(async () => {
|
|
48
|
-
const parsed = parseCommandRequest(raw);
|
|
49
|
-
const command = parsed ? gameAdapter.parseCommand(parsed.command) : null;
|
|
50
|
-
if (!parsed || command === null) {
|
|
51
|
-
this.send(socket, { type: "ProtocolErrorEvent", error: { code: "InvalidRequest" } });
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
const attachment: unknown = socket.deserializeAttachment();
|
|
56
|
-
const state = await this.ctx.storage.get<State>(STATE_KEY);
|
|
57
|
-
if (state === undefined || !isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) {
|
|
58
|
-
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "NotSessionMember" } });
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
const result = gameAdapter.game.handleCommand(state, command, { origin: "actor", actorId: attachment.actorId });
|
|
62
|
-
if (!result.ok) {
|
|
63
|
-
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: result.error });
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
await this.ctx.storage.put(STATE_KEY, result.state);
|
|
67
|
-
// Effect は現在 never。実装時もこの保存の後に実行し、配送失敗で rollback しない。
|
|
68
|
-
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: true });
|
|
69
|
-
this.broadcast(result.state);
|
|
70
|
-
} catch {
|
|
71
|
-
console.error("Session command failed");
|
|
72
|
-
this.send(socket, { type: "GameCommandResponse", requestId: parsed.requestId, ok: false, error: { code: "InternalError" } });
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
private broadcast(state: State): void {
|
|
78
|
-
for (const socket of this.ctx.getWebSockets()) {
|
|
79
|
-
try {
|
|
80
|
-
const attachment: unknown = socket.deserializeAttachment();
|
|
81
|
-
if (!isRecord(attachment) || typeof attachment.actorId !== "string" || !gameAdapter.canConnect(state, attachment.actorId)) continue;
|
|
82
|
-
this.send(socket, { type: "ViewStateEvent", viewState: gameAdapter.game.project(state, attachment.actorId) });
|
|
83
|
-
} catch {
|
|
84
|
-
console.error("Session view delivery failed");
|
|
85
|
-
this.close(socket);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
private send(socket: WebSocket, message: ServerMessage): void {
|
|
91
|
-
try { socket.send(JSON.stringify(message)); } catch { this.close(socket); }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
private close(socket: WebSocket): void {
|
|
95
|
-
try { socket.close(1011, "Delivery failed"); } catch { /* 切断済み。確定済みの保存結果へ影響させない。 */ }
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
webSocketClose(socket: WebSocket, code: number, reason: string): void { socket.close(code, reason); }
|
|
99
|
-
webSocketError(socket: WebSocket): void { socket.close(1011, "Connection error"); }
|
|
100
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
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
|
-
}
|
|
File without changes
|