@uzuhq/code-cli 0.5.7 → 0.5.9
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/dist/cli.js +2516 -289
- package/dist/dev-server/sdk-server-shim.js +21 -0
- package/dist/harness/client-entry.js +2779 -74
- package/package.json +5 -4
- package/dist/auth/browser.js +0 -22
- package/dist/auth/config.js +0 -48
- package/dist/auth/env.js +0 -42
- package/dist/auth/jwt.js +0 -27
- package/dist/auth/login-flow.js +0 -44
- package/dist/auth/loopback.js +0 -94
- package/dist/auth/pkce.js +0 -11
- package/dist/auth/publish-token.js +0 -74
- package/dist/auth/token-cache.js +0 -70
- package/dist/auth/uzu-auth.js +0 -94
- package/dist/build-server-logic.js +0 -28
- package/dist/cf-images-upload.js +0 -52
- package/dist/create-2d-game.js +0 -51
- package/dist/dev-server/game-room.js +0 -631
- package/dist/dev-server/game-types.js +0 -11
- package/dist/dev-server/json-patch.js +0 -114
- package/dist/dev-server/load-logic.js +0 -70
- package/dist/dev-server/random.js +0 -35
- package/dist/dev-server/relay-room.js +0 -86
- package/dist/dev-server/server.js +0 -367
- package/dist/dev-server/sync-room.js +0 -270
- package/dist/dev.js +0 -235
- package/dist/harness/admin-client.js +0 -215
- package/dist/harness/dev-button.js +0 -258
- package/dist/harness/mount.js +0 -790
- package/dist/harness/page.js +0 -46
- package/dist/r2-upload.js +0 -93
- package/dist/rest-register.js +0 -52
- package/dist/sdk-version.js +0 -41
- package/dist/upload-session.js +0 -71
package/dist/create-2d-game.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
-
import { resolve, dirname, join } from 'path';
|
|
3
|
-
import { fileURLToPath } from 'url';
|
|
4
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
5
|
-
/** テンプレートディレクトリから再帰的にファイルをコピー。.tpl ファイルは置換処理を行う。 */
|
|
6
|
-
const copyDir = (src, dest, replacements) => {
|
|
7
|
-
mkdirSync(dest, { recursive: true });
|
|
8
|
-
for (const entry of readdirSync(src)) {
|
|
9
|
-
const srcPath = join(src, entry);
|
|
10
|
-
const stat = statSync(srcPath);
|
|
11
|
-
if (stat.isDirectory()) {
|
|
12
|
-
copyDir(srcPath, join(dest, entry), replacements);
|
|
13
|
-
continue;
|
|
14
|
-
}
|
|
15
|
-
if (entry.endsWith('.tpl')) {
|
|
16
|
-
// テンプレートファイル: プレースホルダーを置換して拡張子を除いた名前で出力
|
|
17
|
-
let content = readFileSync(srcPath, 'utf-8');
|
|
18
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
19
|
-
content = content.replaceAll(`{{${key}}}`, value);
|
|
20
|
-
}
|
|
21
|
-
writeFileSync(join(dest, entry.replace(/\.tpl$/, '')), content);
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
// 通常ファイル: テキストなら置換、バイナリならそのままコピー
|
|
25
|
-
const content = readFileSync(srcPath, 'utf-8');
|
|
26
|
-
let output = content;
|
|
27
|
-
for (const [key, value] of Object.entries(replacements)) {
|
|
28
|
-
output = output.replaceAll(`{{${key}}}`, value);
|
|
29
|
-
}
|
|
30
|
-
writeFileSync(join(dest, entry), output);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
/** kebab-case をタイトルケースに変換 */
|
|
35
|
-
const toTitle = (name) => name
|
|
36
|
-
.split('-')
|
|
37
|
-
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
38
|
-
.join(' ');
|
|
39
|
-
export const create2dGame = (name) => {
|
|
40
|
-
const dest = resolve(process.cwd(), name);
|
|
41
|
-
const templateDir = resolve(__dirname, '..', 'game-2d-template');
|
|
42
|
-
const title = toTitle(name);
|
|
43
|
-
const replacements = { name, title };
|
|
44
|
-
console.log(`Creating 2D game project: ${name}`);
|
|
45
|
-
copyDir(templateDir, dest, replacements);
|
|
46
|
-
console.log(`\nDone! Created ${name}/`);
|
|
47
|
-
console.log(`\nNext steps:`);
|
|
48
|
-
console.log(` cd ${name}`);
|
|
49
|
-
console.log(` npm install`);
|
|
50
|
-
console.log(` npm run dev`);
|
|
51
|
-
};
|
|
@@ -1,631 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dev-server 版 GameRoom (ServerAction / run() モード用)。
|
|
3
|
-
*
|
|
4
|
-
* uzu-code play-server の `game-worker-template/game-room.ts` (Cloudflare Worker DO 版)
|
|
5
|
-
* と同一プロトコル (`__game_start` / `__tick` / `__tick_delta` / `__action_result` /
|
|
6
|
-
* `__action_result_delta` / `__state` / `__room_init` / `__action_error`) を喋る
|
|
7
|
-
* Node.js 版。 SDK 側の `runOnlineServerAction` client がそのまま接続できる。
|
|
8
|
-
*
|
|
9
|
-
* 本番との差分:
|
|
10
|
-
* - Durable Object storage / hibernation なし (in-memory only)。CLI restart で state 消失。
|
|
11
|
-
* - WebSocketPair / acceptWebSocket → `ws` package の raw WebSocket + Set 管理
|
|
12
|
-
* - setWebSocketAutoResponse (__ping/__pong) → message handler で直接返す
|
|
13
|
-
*/
|
|
14
|
-
import { randomUUID } from 'crypto';
|
|
15
|
-
import { compare, applyJsonMergePatch, applyJsonPatch } from './json-patch.js';
|
|
16
|
-
import { SeededRandomImpl } from './random.js';
|
|
17
|
-
/**
|
|
18
|
-
* roster の申告を player だけに落とす。 壊れた申告は null。
|
|
19
|
-
*
|
|
20
|
-
* 観測者は roster に載らない。 旧ホストは観測席も載せてくるので、 配役に混ざらないよう
|
|
21
|
-
* ここで落とす。
|
|
22
|
-
*/
|
|
23
|
-
function parseRoster(rosterParam) {
|
|
24
|
-
if (!rosterParam)
|
|
25
|
-
return null;
|
|
26
|
-
let raw;
|
|
27
|
-
try {
|
|
28
|
-
raw = JSON.parse(rosterParam);
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
if (!Array.isArray(raw))
|
|
34
|
-
return null;
|
|
35
|
-
return raw
|
|
36
|
-
.filter((p) => (p.kind ?? 'player') === 'player')
|
|
37
|
-
.map((p) => ({
|
|
38
|
-
id: p.id,
|
|
39
|
-
nickname: p.name ?? 'Guest',
|
|
40
|
-
iconUrl: p.iconUrl ?? '',
|
|
41
|
-
characterId: p.characterId,
|
|
42
|
-
}));
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* serverOnly() で wrap された handler かを判定する (SDK の isServerOnlyAction と同じ brand)。
|
|
46
|
-
* dev-server は SDK を import できないため実装を持つ。
|
|
47
|
-
*/
|
|
48
|
-
function isServerOnlyAction(handler) {
|
|
49
|
-
// handler は logic.actions[name] の結果なので undefined になりうる。型上は
|
|
50
|
-
// undefined を含まない (noUncheckedIndexedAccess 無効) ため tsc では気付けない。
|
|
51
|
-
// typeof ガードが無いと `'__serverOnly' in undefined` で TypeError になる。
|
|
52
|
-
return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
|
|
53
|
-
}
|
|
54
|
-
export class GameRoom {
|
|
55
|
-
logic;
|
|
56
|
-
tickRate;
|
|
57
|
-
gameState = null;
|
|
58
|
-
stateInitialized = false;
|
|
59
|
-
random = null;
|
|
60
|
-
seed = 0;
|
|
61
|
-
tickCount = 0;
|
|
62
|
-
tickTimer = null;
|
|
63
|
-
tickPaused = false;
|
|
64
|
-
playerInputs = {};
|
|
65
|
-
/**
|
|
66
|
-
* roster。 配役を受け取る player 席のみで、 観測席 (GM 席・観戦席) は載らない。
|
|
67
|
-
* dev harness では manifest から組んだ roster を constructor で注入する
|
|
68
|
-
* (server 権威)。 その場合、 接続クエリの roster 申告は一切採用しないので、
|
|
69
|
-
* 旧世代 harness page の残タブが reconnect しても roster を汚染できない。
|
|
70
|
-
* 本番 DO は backend 由来の roster を全 client が同一申告するため接続時
|
|
71
|
-
* 登録で成立している — 権威が platform 側にある点は同じ。
|
|
72
|
-
*/
|
|
73
|
-
players = [];
|
|
74
|
-
seq = 0;
|
|
75
|
-
prevBroadcastState = null;
|
|
76
|
-
static SNAPSHOT_INTERVAL = 20;
|
|
77
|
-
sockets = new Set();
|
|
78
|
-
attachments = new WeakMap();
|
|
79
|
-
snapshotSubscribers = new Set();
|
|
80
|
-
eventSubscribers = new Set();
|
|
81
|
-
constructor(logic, players) {
|
|
82
|
-
this.logic = logic;
|
|
83
|
-
this.tickRate = logic.tickRate ?? 0;
|
|
84
|
-
if (players && players.length > 0) {
|
|
85
|
-
this.players = players;
|
|
86
|
-
console.log(`[GameRoom] 📋 Roster (server-authoritative): ${players.map((p) => p.id).join(', ')}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
// ─── Broadcast ─────────────────────────────────────────
|
|
90
|
-
broadcastAll(msg) {
|
|
91
|
-
const data = JSON.stringify(msg);
|
|
92
|
-
for (const ws of this.sockets) {
|
|
93
|
-
try {
|
|
94
|
-
ws.send(data);
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
/* disconnected */
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
sendTo(ws, msg) {
|
|
102
|
-
try {
|
|
103
|
-
ws.send(JSON.stringify(msg));
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
/* disconnected */
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
broadcastStateDelta(events, extra) {
|
|
110
|
-
this.seq++;
|
|
111
|
-
// クライアントはこの値でクロックオフセットを合わせる (ctx.now の推定に使う)。
|
|
112
|
-
const serverTime = Date.now();
|
|
113
|
-
const fullType = extra.ack !== undefined ? '__action_result' : '__tick';
|
|
114
|
-
const deltaType = extra.ack !== undefined ? '__action_result_delta' : '__tick_delta';
|
|
115
|
-
const needFull = this.prevBroadcastState === null || this.seq % GameRoom.SNAPSHOT_INTERVAL === 0;
|
|
116
|
-
if (needFull) {
|
|
117
|
-
this.broadcastAll({
|
|
118
|
-
type: fullType,
|
|
119
|
-
state: this.gameState,
|
|
120
|
-
events,
|
|
121
|
-
seq: this.seq,
|
|
122
|
-
serverTime,
|
|
123
|
-
...extra,
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
const patches = compare(this.prevBroadcastState, this.gameState);
|
|
128
|
-
const deltaPayload = JSON.stringify({
|
|
129
|
-
type: deltaType,
|
|
130
|
-
patches,
|
|
131
|
-
events,
|
|
132
|
-
seq: this.seq,
|
|
133
|
-
serverTime,
|
|
134
|
-
...extra,
|
|
135
|
-
});
|
|
136
|
-
const fullPayload = JSON.stringify({
|
|
137
|
-
type: fullType,
|
|
138
|
-
state: this.gameState,
|
|
139
|
-
events,
|
|
140
|
-
seq: this.seq,
|
|
141
|
-
serverTime,
|
|
142
|
-
...extra,
|
|
143
|
-
});
|
|
144
|
-
const data = deltaPayload.length < fullPayload.length ? deltaPayload : fullPayload;
|
|
145
|
-
for (const ws of this.sockets) {
|
|
146
|
-
try {
|
|
147
|
-
ws.send(data);
|
|
148
|
-
}
|
|
149
|
-
catch {
|
|
150
|
-
/* disconnected */
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
this.prevBroadcastState = structuredClone(this.gameState);
|
|
155
|
-
this.notifySnapshotSubscribers();
|
|
156
|
-
if (events.length > 0)
|
|
157
|
-
this.notifyEventSubscribers(events);
|
|
158
|
-
}
|
|
159
|
-
notifySnapshotSubscribers() {
|
|
160
|
-
this.snapshotSubscribers.forEach((cb) => {
|
|
161
|
-
try {
|
|
162
|
-
cb(this.gameState);
|
|
163
|
-
}
|
|
164
|
-
catch (err) {
|
|
165
|
-
console.warn('[GameRoom] snapshot subscriber threw:', err);
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
notifyEventSubscribers(events) {
|
|
170
|
-
const frozen = Object.freeze(events.slice());
|
|
171
|
-
this.eventSubscribers.forEach((cb) => {
|
|
172
|
-
try {
|
|
173
|
-
cb(frozen);
|
|
174
|
-
}
|
|
175
|
-
catch (err) {
|
|
176
|
-
console.warn('[GameRoom] event subscriber threw:', err);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
// ─── Game Lifecycle ────────────────────────────────────
|
|
181
|
-
/**
|
|
182
|
-
* 移行期: 手元のシナリオが `setup({ seats })` のままでも `uzu dev` を動かせるよう、
|
|
183
|
-
* 同じ配列を旧名でも渡す。 本番 (play-server) が publish 済み logic.js のために
|
|
184
|
-
* 同じことをしているのと揃えている。 `SetupArgs` に `seats` を宣言しないのは、
|
|
185
|
-
* 新規シナリオに旧名を選ばせないため。
|
|
186
|
-
*/
|
|
187
|
-
setupArgs(random) {
|
|
188
|
-
return {
|
|
189
|
-
players: this.players,
|
|
190
|
-
seats: this.players,
|
|
191
|
-
ctx: { random, now: Date.now() },
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
maybeStartGame() {
|
|
195
|
-
if (this.gameState !== null)
|
|
196
|
-
return;
|
|
197
|
-
if (this.players.length === 0)
|
|
198
|
-
return;
|
|
199
|
-
this.seed = Date.now() & 0xffffffff;
|
|
200
|
-
this.random = new SeededRandomImpl(this.seed);
|
|
201
|
-
this.gameState = this.logic.setup(this.setupArgs(this.random));
|
|
202
|
-
this.stateInitialized = true;
|
|
203
|
-
this.tickCount = 0;
|
|
204
|
-
this.seq = 0;
|
|
205
|
-
this.prevBroadcastState = structuredClone(this.gameState);
|
|
206
|
-
console.log(`[GameRoom] ✅ Game started with ${this.players.length} players`);
|
|
207
|
-
this.broadcastAll({
|
|
208
|
-
type: '__game_start',
|
|
209
|
-
state: this.gameState,
|
|
210
|
-
seed: this.seed,
|
|
211
|
-
seq: 0,
|
|
212
|
-
});
|
|
213
|
-
if (this.tickRate > 0)
|
|
214
|
-
this.startTickLoop();
|
|
215
|
-
this.notifySnapshotSubscribers();
|
|
216
|
-
}
|
|
217
|
-
startTickLoop() {
|
|
218
|
-
if (this.tickTimer)
|
|
219
|
-
clearInterval(this.tickTimer);
|
|
220
|
-
this.tickTimer = setInterval(() => this.tick(), 1000 / this.tickRate);
|
|
221
|
-
}
|
|
222
|
-
// 全クライアント切断で tickTimer は止まる (handleClose) が gameState は残るため、
|
|
223
|
-
// 再接続や reset で「動いているべきなのに止まっている」状態を復旧する。
|
|
224
|
-
ensureTickLoop() {
|
|
225
|
-
if (this.tickRate > 0 &&
|
|
226
|
-
this.gameState !== null &&
|
|
227
|
-
this.sockets.size > 0 &&
|
|
228
|
-
!this.tickPaused &&
|
|
229
|
-
!this.tickTimer) {
|
|
230
|
-
this.startTickLoop();
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
tick() {
|
|
234
|
-
if (this.tickPaused)
|
|
235
|
-
return;
|
|
236
|
-
this.runOneTick();
|
|
237
|
-
}
|
|
238
|
-
runOneTick() {
|
|
239
|
-
if (!this.gameState || !this.random)
|
|
240
|
-
return;
|
|
241
|
-
const events = [];
|
|
242
|
-
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
243
|
-
// ctx.now と予約の基準時刻は同じ値にする。別々に Date.now() を呼ぶと
|
|
244
|
-
const tickNow = Date.now();
|
|
245
|
-
try {
|
|
246
|
-
this.logic.update({
|
|
247
|
-
state: this.gameState,
|
|
248
|
-
ctx: {
|
|
249
|
-
random: this.random,
|
|
250
|
-
tick: this.tickCount,
|
|
251
|
-
now: tickNow,
|
|
252
|
-
emit,
|
|
253
|
-
playerInputs: this.playerInputs,
|
|
254
|
-
},
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
catch (err) {
|
|
258
|
-
console.error(`[GameRoom] tick error at tick=${this.tickCount}:`, err);
|
|
259
|
-
this.tickCount++;
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
this.tickCount++;
|
|
263
|
-
this.syncWakeup();
|
|
264
|
-
this.broadcastStateDelta(events, { tick: this.tickCount });
|
|
265
|
-
}
|
|
266
|
-
// ─── Connection ────────────────────────────────────────
|
|
267
|
-
handleConnection(ws, url) {
|
|
268
|
-
const playerId = url.searchParams.get('seatId');
|
|
269
|
-
if (!playerId) {
|
|
270
|
-
ws.close(1008, 'Missing required query parameter: seatId');
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
const nickname = url.searchParams.get('nickname') ?? 'Guest';
|
|
274
|
-
const connectionId = randomUUID();
|
|
275
|
-
// roster に自席が居なくても弾かない。 観測席 (GM 席・観戦席) は roster に載らない
|
|
276
|
-
// まま接続してくる。 シナリオ側は state.players の空振りで観測者ビューを返す。
|
|
277
|
-
if (this.players.length === 0) {
|
|
278
|
-
// constructor 注入 (server 権威) が無い場合のみ、 接続クエリの申告 roster に
|
|
279
|
-
// fallback する (本番 DO と同じ経路)。 dev harness では常に注入済みなので
|
|
280
|
-
// ここは通らない。
|
|
281
|
-
// 移行期: 旧 SDK を同梱したシナリオは `seats=` を送り続ける。
|
|
282
|
-
const declared = parseRoster(url.searchParams.get('players') ?? url.searchParams.get('seats'));
|
|
283
|
-
if (declared && declared.length > 0) {
|
|
284
|
-
this.players = declared;
|
|
285
|
-
console.log(`[GameRoom] 📋 Roster pinned: ${this.players.map((p) => p.id).join(', ')}`);
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
console.log(`[GameRoom] 🔗 New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`);
|
|
289
|
-
this.sockets.add(ws);
|
|
290
|
-
this.attachments.set(ws, { connectionId, playerId, nickname });
|
|
291
|
-
ws.on('message', (raw) => {
|
|
292
|
-
void this.handleMessage(ws, raw.toString());
|
|
293
|
-
});
|
|
294
|
-
ws.on('close', () => this.handleClose(ws));
|
|
295
|
-
ws.on('error', () => this.handleClose(ws));
|
|
296
|
-
this.sendTo(ws, {
|
|
297
|
-
type: '__room_init',
|
|
298
|
-
myId: playerId,
|
|
299
|
-
});
|
|
300
|
-
if (this.stateInitialized && this.gameState !== null) {
|
|
301
|
-
this.sendTo(ws, {
|
|
302
|
-
type: '__state',
|
|
303
|
-
state: this.gameState,
|
|
304
|
-
tick: this.tickCount,
|
|
305
|
-
seq: this.seq,
|
|
306
|
-
serverTime: Date.now(),
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
this.maybeStartGame();
|
|
310
|
-
// 既存ゲームへの再接続では maybeStartGame は early-return するので、
|
|
311
|
-
// 全切断で止まった tick loop をここで復旧する。
|
|
312
|
-
this.ensureTickLoop();
|
|
313
|
-
}
|
|
314
|
-
async handleMessage(ws, msg) {
|
|
315
|
-
// SDK は生文字列で送受信する (本番の setWebSocketAutoResponse と同じ wire 形式)。
|
|
316
|
-
// JSON.parse より前に返さないと heartbeat が落ちて 35 秒ごとに全席が再接続する。
|
|
317
|
-
if (msg === '__ping') {
|
|
318
|
-
try {
|
|
319
|
-
ws.send('__pong');
|
|
320
|
-
}
|
|
321
|
-
catch {
|
|
322
|
-
/* disconnected */
|
|
323
|
-
}
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
const attachment = this.attachments.get(ws);
|
|
327
|
-
if (!attachment)
|
|
328
|
-
return;
|
|
329
|
-
const senderId = attachment.playerId;
|
|
330
|
-
let parsed;
|
|
331
|
-
try {
|
|
332
|
-
parsed = JSON.parse(msg);
|
|
333
|
-
}
|
|
334
|
-
catch {
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
const msgType = parsed.type;
|
|
338
|
-
console.log(`[GameRoom] ⬅ recv from=${senderId} type=${msgType}`);
|
|
339
|
-
if (msgType === '__action') {
|
|
340
|
-
if (!this.gameState) {
|
|
341
|
-
this.sendTo(ws, {
|
|
342
|
-
type: '__action_error',
|
|
343
|
-
error: 'Game not started',
|
|
344
|
-
seq: parsed.seq,
|
|
345
|
-
});
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
const actionName = parsed.action;
|
|
349
|
-
const payload = parsed.payload ?? {};
|
|
350
|
-
const seq = parsed.seq;
|
|
351
|
-
try {
|
|
352
|
-
await this.dispatchAction(actionName, payload, senderId, seq);
|
|
353
|
-
}
|
|
354
|
-
catch (err) {
|
|
355
|
-
this.sendTo(ws, {
|
|
356
|
-
type: '__action_error',
|
|
357
|
-
error: `Action failed: ${err instanceof Error ? err.message : 'unknown'}`,
|
|
358
|
-
seq,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
if (msgType === '__request_state') {
|
|
364
|
-
if (this.gameState !== null) {
|
|
365
|
-
this.sendTo(ws, {
|
|
366
|
-
type: '__state',
|
|
367
|
-
state: this.gameState,
|
|
368
|
-
tick: this.tickCount,
|
|
369
|
-
seq: this.seq,
|
|
370
|
-
serverTime: Date.now(),
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
if (msgType === '__input') {
|
|
376
|
-
const inputData = parsed.data;
|
|
377
|
-
if (inputData)
|
|
378
|
-
this.playerInputs[senderId] = inputData;
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
async dispatchAction(actionName, payload, senderId, ackSeq) {
|
|
383
|
-
const plain = this.logic.actions[actionName];
|
|
384
|
-
// 移行期の互換: 旧 serverOnly() を actions に入れたままの logic も動かす。
|
|
385
|
-
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
386
|
-
const serverHandler = this.logic.serverActions?.[actionName] ?? legacyServerOnly;
|
|
387
|
-
if (!plain && !serverHandler) {
|
|
388
|
-
throw new Error(`Unknown action: ${actionName}`);
|
|
389
|
-
}
|
|
390
|
-
if (!this.gameState) {
|
|
391
|
-
throw new Error('Game not started');
|
|
392
|
-
}
|
|
393
|
-
// actions 由来か serverActions 由来かは区別せず 1 本のリストで送る。
|
|
394
|
-
// 先読みで発火済みかどうかの判定はクライアントが自分の記録と突き合わせて行う。
|
|
395
|
-
const events = [];
|
|
396
|
-
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
397
|
-
const serverEmit = emit;
|
|
398
|
-
// action は「全部成功か全部失敗か」にする。plain が state を変更したあと
|
|
399
|
-
// serverActions が throw すると、caller の catch が __action_error を返して
|
|
400
|
-
// broadcast されないまま変更が gameState に残り、次の無関係な broadcast の diff に
|
|
401
|
-
// 紛れて漏れる。クライアントは __action_error で予測を rollback するので、
|
|
402
|
-
// 巻き戻さないとサーバー真実と表示が食い違ったままになる。
|
|
403
|
-
const snapshot = structuredClone(this.gameState);
|
|
404
|
-
// actions と serverActions には同じ時刻を渡す。同一 dispatch の前半と後半で
|
|
405
|
-
// 値が変わると、片方だけを見た state が一瞬矛盾する。
|
|
406
|
-
const now = Date.now();
|
|
407
|
-
// handler 内では配列を触るだけにして、反映は dispatch 後にまとめて行う。
|
|
408
|
-
try {
|
|
409
|
-
// 決定的な部分を先に走らせ、serverActions がその結果を見られるようにする。
|
|
410
|
-
if (plain && !legacyServerOnly) {
|
|
411
|
-
plain({
|
|
412
|
-
state: this.gameState,
|
|
413
|
-
payload: payload ?? {},
|
|
414
|
-
playerId: senderId,
|
|
415
|
-
ctx: { now, emit },
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
if (serverHandler) {
|
|
419
|
-
await serverHandler({
|
|
420
|
-
state: this.gameState,
|
|
421
|
-
payload: payload ?? {},
|
|
422
|
-
playerId: senderId,
|
|
423
|
-
ctx: {
|
|
424
|
-
tick: this.tickCount,
|
|
425
|
-
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
426
|
-
now,
|
|
427
|
-
emit: serverEmit,
|
|
428
|
-
},
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
catch (err) {
|
|
433
|
-
this.gameState = snapshot;
|
|
434
|
-
throw err;
|
|
435
|
-
}
|
|
436
|
-
this.syncWakeup();
|
|
437
|
-
this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
|
|
438
|
-
}
|
|
439
|
-
// ─── Deadlines (本番の Durable Object alarm 相当) ──────────
|
|
440
|
-
/** dev は setTimeout。本番は storage の alarm。意味論は同じ。 */
|
|
441
|
-
wakeupTimer = null;
|
|
442
|
-
wakeupAt = null;
|
|
443
|
-
/**
|
|
444
|
-
* 宣言された締切のうち最も早い時刻。締切が無ければ null。
|
|
445
|
-
* state から毎回導出するので保存しない。
|
|
446
|
-
*/
|
|
447
|
-
nextDeadline() {
|
|
448
|
-
if (!this.gameState || !this.logic.deadlines)
|
|
449
|
-
return null;
|
|
450
|
-
let earliest = null;
|
|
451
|
-
for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
|
|
452
|
-
let at;
|
|
453
|
-
try {
|
|
454
|
-
at = deadline.at({ state: this.gameState });
|
|
455
|
-
}
|
|
456
|
-
catch (err) {
|
|
457
|
-
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
458
|
-
continue;
|
|
459
|
-
}
|
|
460
|
-
if (typeof at !== 'number' || !Number.isFinite(at))
|
|
461
|
-
continue;
|
|
462
|
-
if (earliest === null || at < earliest)
|
|
463
|
-
earliest = at;
|
|
464
|
-
}
|
|
465
|
-
return earliest;
|
|
466
|
-
}
|
|
467
|
-
/** 起床時刻を現在の state に合わせる。state を変えた後は必ず通す。 */
|
|
468
|
-
syncWakeup() {
|
|
469
|
-
const next = this.nextDeadline();
|
|
470
|
-
if (next === this.wakeupAt)
|
|
471
|
-
return;
|
|
472
|
-
if (this.wakeupTimer)
|
|
473
|
-
clearTimeout(this.wakeupTimer);
|
|
474
|
-
this.wakeupTimer = null;
|
|
475
|
-
this.wakeupAt = next;
|
|
476
|
-
if (next === null)
|
|
477
|
-
return;
|
|
478
|
-
this.wakeupTimer = setTimeout(() => this.fireDue(), Math.max(0, next - Date.now()));
|
|
479
|
-
// dev の setTimeout は process を掴み続けるので、他に仕事が無ければ抜けられるようにする。
|
|
480
|
-
this.wakeupTimer.unref?.();
|
|
481
|
-
}
|
|
482
|
-
/**
|
|
483
|
-
* 過ぎた締切の handler を実行する。本番の alarm() と同じ意味論。
|
|
484
|
-
*
|
|
485
|
-
* handler の直前に at を評価し直す (先に走った handler が別の締切を消しうる)。
|
|
486
|
-
* 失敗は握って捨てる。ログには必ず残す。
|
|
487
|
-
*/
|
|
488
|
-
fireDue() {
|
|
489
|
-
this.wakeupTimer = null;
|
|
490
|
-
this.wakeupAt = null;
|
|
491
|
-
if (!this.gameState || !this.logic.deadlines)
|
|
492
|
-
return;
|
|
493
|
-
const now = Date.now();
|
|
494
|
-
const events = [];
|
|
495
|
-
const firedKeys = [];
|
|
496
|
-
for (const [key, deadline] of Object.entries(this.logic.deadlines)) {
|
|
497
|
-
// 巻き戻しで this.gameState が差し替わるので、毎周読み直す。
|
|
498
|
-
const state = this.gameState;
|
|
499
|
-
if (!state)
|
|
500
|
-
break;
|
|
501
|
-
// handler が emit してから throw したときに捨てられるよう、締切ごとに溜める。
|
|
502
|
-
// state を戻したのに音や演出だけ流れると、起きていない出来事が見えてしまう。
|
|
503
|
-
const pending = [];
|
|
504
|
-
let snapshot = null;
|
|
505
|
-
try {
|
|
506
|
-
const at = deadline.at({ state });
|
|
507
|
-
if (typeof at !== 'number' || !Number.isFinite(at) || at > now)
|
|
508
|
-
continue;
|
|
509
|
-
// 期限が来たものだけ複製する。毎周撮ると締切の数だけ state の複製が走る。
|
|
510
|
-
snapshot = structuredClone(state);
|
|
511
|
-
deadline.handler({
|
|
512
|
-
state,
|
|
513
|
-
ctx: {
|
|
514
|
-
now,
|
|
515
|
-
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
516
|
-
emit: (name, data) => pending.push({ name, data: data ?? {} }),
|
|
517
|
-
},
|
|
518
|
-
});
|
|
519
|
-
events.push(...pending);
|
|
520
|
-
firedKeys.push(key);
|
|
521
|
-
}
|
|
522
|
-
catch (err) {
|
|
523
|
-
if (snapshot !== null)
|
|
524
|
-
this.gameState = snapshot;
|
|
525
|
-
console.error(`[Deadline] ❌ ${key} で例外`, err);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
this.syncWakeup();
|
|
529
|
-
if (firedKeys.length === 0)
|
|
530
|
-
return;
|
|
531
|
-
console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(', ')}`);
|
|
532
|
-
this.broadcastStateDelta(events, {});
|
|
533
|
-
}
|
|
534
|
-
handleClose(ws) {
|
|
535
|
-
const attachment = this.attachments.get(ws);
|
|
536
|
-
if (!attachment)
|
|
537
|
-
return;
|
|
538
|
-
console.log(`[GameRoom] ❌ Disconnected: connectionId=${attachment.connectionId} playerId=${attachment.playerId}`);
|
|
539
|
-
this.sockets.delete(ws);
|
|
540
|
-
this.attachments.delete(ws);
|
|
541
|
-
delete this.playerInputs[attachment.playerId];
|
|
542
|
-
if (this.sockets.size === 0 && this.tickTimer) {
|
|
543
|
-
clearInterval(this.tickTimer);
|
|
544
|
-
this.tickTimer = null;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
// ─── Admin API (parent frame __uzu_dev から使う) ──────────────────
|
|
548
|
-
admin() {
|
|
549
|
-
return {
|
|
550
|
-
getSnapshot: () => this.gameState,
|
|
551
|
-
getRawState: () => this.gameState,
|
|
552
|
-
setRawState: (next) => {
|
|
553
|
-
this.gameState = next;
|
|
554
|
-
this.broadcastStateDelta([], { tick: this.tickCount });
|
|
555
|
-
},
|
|
556
|
-
mergeRawState: (patch) => {
|
|
557
|
-
if (!this.gameState)
|
|
558
|
-
return;
|
|
559
|
-
applyJsonMergePatch(this.gameState, patch);
|
|
560
|
-
this.broadcastStateDelta([], { tick: this.tickCount });
|
|
561
|
-
},
|
|
562
|
-
patchRawState: (ops) => {
|
|
563
|
-
if (!this.gameState)
|
|
564
|
-
return;
|
|
565
|
-
applyJsonPatch(this.gameState, ops);
|
|
566
|
-
this.broadcastStateDelta([], { tick: this.tickCount });
|
|
567
|
-
},
|
|
568
|
-
sendAction: async ({ as, type, payload }) => {
|
|
569
|
-
await this.dispatchAction(type, payload, as);
|
|
570
|
-
},
|
|
571
|
-
getSeed: () => this.seed,
|
|
572
|
-
pauseTick: () => {
|
|
573
|
-
if (this.tickRate <= 0)
|
|
574
|
-
return;
|
|
575
|
-
this.tickPaused = true;
|
|
576
|
-
},
|
|
577
|
-
resumeTick: () => {
|
|
578
|
-
if (this.tickRate <= 0)
|
|
579
|
-
return;
|
|
580
|
-
this.tickPaused = false;
|
|
581
|
-
},
|
|
582
|
-
stepTick: (n = 1) => {
|
|
583
|
-
if (this.tickRate <= 0)
|
|
584
|
-
return;
|
|
585
|
-
if (!Number.isInteger(n) || n < 0) {
|
|
586
|
-
throw new RangeError(`stepTick: n must be a non-negative integer (got ${n})`);
|
|
587
|
-
}
|
|
588
|
-
for (let i = 0; i < n; i++)
|
|
589
|
-
this.runOneTick();
|
|
590
|
-
},
|
|
591
|
-
getCurrentTick: () => this.tickCount,
|
|
592
|
-
isTickPaused: () => this.tickPaused,
|
|
593
|
-
reset: (opts) => {
|
|
594
|
-
if (opts?.seed === 'random') {
|
|
595
|
-
this.seed = Math.floor(Math.random() * 0xffffffff);
|
|
596
|
-
}
|
|
597
|
-
else if (typeof opts?.seed === 'number') {
|
|
598
|
-
this.seed = opts.seed;
|
|
599
|
-
}
|
|
600
|
-
this.random = new SeededRandomImpl(this.seed);
|
|
601
|
-
this.gameState = this.logic.setup(this.setupArgs(this.random));
|
|
602
|
-
this.tickCount = 0;
|
|
603
|
-
// reset は新規ゲーム = 実行可能状態を意味するので pause も解除する。
|
|
604
|
-
this.tickPaused = false;
|
|
605
|
-
this.seq = 0;
|
|
606
|
-
this.prevBroadcastState = structuredClone(this.gameState);
|
|
607
|
-
this.broadcastAll({
|
|
608
|
-
type: '__game_start',
|
|
609
|
-
state: this.gameState,
|
|
610
|
-
seed: this.seed,
|
|
611
|
-
seq: 0,
|
|
612
|
-
});
|
|
613
|
-
this.notifySnapshotSubscribers();
|
|
614
|
-
// 全切断中に reset された後の再接続でも tick が回るよう復旧する。
|
|
615
|
-
this.ensureTickLoop();
|
|
616
|
-
},
|
|
617
|
-
subscribeSnapshot: (cb) => {
|
|
618
|
-
this.snapshotSubscribers.add(cb);
|
|
619
|
-
return () => {
|
|
620
|
-
this.snapshotSubscribers.delete(cb);
|
|
621
|
-
};
|
|
622
|
-
},
|
|
623
|
-
subscribeEvents: (cb) => {
|
|
624
|
-
this.eventSubscribers.add(cb);
|
|
625
|
-
return () => {
|
|
626
|
-
this.eventSubscribers.delete(cb);
|
|
627
|
-
};
|
|
628
|
-
},
|
|
629
|
-
};
|
|
630
|
-
}
|
|
631
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @docs
|
|
3
|
-
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
4
|
-
*
|
|
5
|
-
* Node 側 dev-server 用の game type 定義。
|
|
6
|
-
* SDK `src/types.ts` および uzu-code play-server `game-worker-template/game-types.ts` と
|
|
7
|
-
* 同一 shape。 CLI は browser 依存 (window etc) を持たない Node 環境で走るので、
|
|
8
|
-
* SDK を直接 import できず、game-worker-template と同じく copy を持つ。
|
|
9
|
-
* 3 か所いずれかを変更したら他 2 か所も同期させること。
|
|
10
|
-
*/
|
|
11
|
-
export {};
|