@uzuhq/code-sdk 0.7.0 → 0.7.2
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 +63 -25
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/run/local-server-action.js +84 -31
- package/dist/run/optimistic-action-client.js +2 -14
- package/dist/run/optimistic-action-client.test.js +0 -25
- package/dist/types.d.ts +59 -36
- package/dist/types.js +0 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -219,61 +219,99 @@ run({
|
|
|
219
219
|
### GameLogic
|
|
220
220
|
|
|
221
221
|
```ts
|
|
222
|
-
import {
|
|
222
|
+
import type { GameLogic } from '@uzuhq/code-sdk';
|
|
223
223
|
|
|
224
224
|
const logic: GameLogic<MyState> = {
|
|
225
|
-
setup(seats,
|
|
226
|
-
// 初期 state を生成。random は SeededRandom
|
|
225
|
+
setup({ seats, ctx }) {
|
|
226
|
+
// 初期 state を生成。ctx.random は SeededRandom、ctx.now はサーバーの実時刻。
|
|
227
227
|
// seats には観戦系の席 (kind: 'spectator' | 'admin') も含まれ得るため、
|
|
228
228
|
// ゲームの席は kind === 'player' に絞る
|
|
229
229
|
return { players: {}, items: [] };
|
|
230
230
|
},
|
|
231
231
|
|
|
232
|
+
// クライアント先読みとサーバーの 2 回走る。決定的でなければならない。
|
|
232
233
|
actions: {
|
|
233
|
-
move(state, payload, playerId,
|
|
234
|
+
move({ state, payload, playerId, ctx }) {
|
|
234
235
|
// state を直接変更する(Immer 的な mutable 操作)
|
|
235
236
|
state.players[playerId].x += payload.dx;
|
|
236
|
-
// emit でイベント発火
|
|
237
|
-
emit('sound', { sound: 'step' });
|
|
238
|
-
// ctx.
|
|
237
|
+
// ctx.emit でイベント発火 (購読側は events で predict を宣言する)
|
|
238
|
+
ctx.emit('sound', { sound: 'step' });
|
|
239
|
+
// 時刻は ctx.now を使う。Date.now() は端末とサーバーでズレる
|
|
239
240
|
},
|
|
241
|
+
},
|
|
240
242
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
+
// サーバーでのみ走る。実時刻・乱数・fetch などクライアントが再現できない処理。
|
|
244
|
+
// actions と同名にすると「同じ action のサーバー側の続き」になる。
|
|
245
|
+
serverActions: {
|
|
246
|
+
async notifyExternal({ state, payload, playerId }) {
|
|
243
247
|
await fetch('https://example.com/notify', {
|
|
244
248
|
method: 'POST',
|
|
245
249
|
body: JSON.stringify({ playerId, ...payload }),
|
|
246
250
|
});
|
|
247
251
|
state.notifiedAt = Date.now();
|
|
248
|
-
}
|
|
252
|
+
},
|
|
249
253
|
},
|
|
250
254
|
|
|
251
|
-
update(state, ctx) {
|
|
252
|
-
// 毎 tick 実行。ctx.tick
|
|
255
|
+
update({ state, ctx }) {
|
|
256
|
+
// 毎 tick 実行。ctx.tick / ctx.random / ctx.now / ctx.emit / ctx.playerInputs が使える
|
|
253
257
|
},
|
|
254
258
|
|
|
255
|
-
tickRate: 10, // 秒間 tick 数 (default: 0 = tick
|
|
259
|
+
tickRate: 10, // 秒間 tick 数 (default: 0 = tick なし)
|
|
256
260
|
};
|
|
257
261
|
```
|
|
258
262
|
|
|
259
|
-
| キー
|
|
260
|
-
|
|
|
261
|
-
| `setup`
|
|
262
|
-
| `actions`
|
|
263
|
-
| `
|
|
264
|
-
| `
|
|
263
|
+
| キー | 型 | 必須 | 説明 |
|
|
264
|
+
| --------------- | ---------------------------------------- | ---- | ---------------------------------------------------------------- |
|
|
265
|
+
| `setup` | `(args: SetupArgs) => S` | Yes | 初期 state を生成。seats には `kind !== 'player'` の席も含まれる |
|
|
266
|
+
| `actions` | `Record<string, ActionHandler<S>>` | Yes | クライアント先読み + サーバーの 2 回走る。決定的であること |
|
|
267
|
+
| `serverActions` | `Record<string, ServerActionHandler<S>>` | No | サーバーでのみ走る。async 可。`ctx` に `tick` / `random` が入る |
|
|
268
|
+
| `update` | `(args: UpdateArgs<S>) => void` | Yes | 毎 tick 実行 (`tickRate` が 0 なら呼ばれない) |
|
|
269
|
+
| `tickRate` | `number` | No | 秒間 tick 数 (default: 0 = tick なし) |
|
|
270
|
+
|
|
271
|
+
ハンドラの引数は 1 つのオブジェクトで、使うものだけ書けばよい。
|
|
272
|
+
`state` / `payload` / `playerId` はその呼び出しの事実、`ctx` は実行環境が与えるもの。
|
|
273
|
+
|
|
274
|
+
#### `deadlines`
|
|
275
|
+
|
|
276
|
+
「state のこの時刻を過ぎたらこれをする」を宣言する。サーバーが最も早い `at` に合わせて
|
|
277
|
+
自分で起き、過ぎた締切の `handler` を呼ぶ。
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
deadlines: {
|
|
281
|
+
phaseTimer: {
|
|
282
|
+
at: ({ state }) => state.timerEndsAt, // null / undefined なら締切なし
|
|
283
|
+
handler: ({ state, ctx }) => {
|
|
284
|
+
state.phase = nextPhase(state);
|
|
285
|
+
ctx.emit('phase.changed', { phase: state.phase });
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
| | |
|
|
292
|
+
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
293
|
+
| `at` | 締切の絶対時刻 (ms)。`null` / `undefined` で締切なし (optional chain の結果をそのまま返せる)。**state だけから決まる軽い関数にすること** (state が変わるたびに呼ばれる) |
|
|
294
|
+
| `handler` | サーバーでのみ走る。`ctx` は `{ now, random, emit }` |
|
|
295
|
+
|
|
296
|
+
締切は state から導出するので、**予約を張り替える処理を書かなくてよい**。action が throw して
|
|
297
|
+
state が巻き戻れば締切も一緒に巻き戻る。発火直前に `at` を評価し直すので、二重発火を
|
|
298
|
+
handler 側で弾く必要も無い。
|
|
299
|
+
|
|
300
|
+
時刻をきっかけに何かを起こすなら `tickRate` で毎秒ポーリングせずこちらを使う。
|
|
301
|
+
ポーリング中は Durable Object が hibernate できない。
|
|
265
302
|
|
|
266
303
|
#### `serverOnly(handler)`
|
|
267
304
|
|
|
268
|
-
`
|
|
305
|
+
> **Deprecated**: `serverActions` に直接書く。
|
|
269
306
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
- handler のシグネチャ: `(state, payload, playerId, emit, ctx) => Promise<void> | void`
|
|
273
|
-
- 例外を投げると送信元クライアントに `__action_error` が返る (state は変更されない)
|
|
307
|
+
`actions` に登録する handler を「サーバーでだけ実行される」ものに変換する旧 wrapper。
|
|
308
|
+
`serverActions` フィールドが同じことを型で表現できるので、新しいコードでは使わない。
|
|
274
309
|
|
|
275
310
|
```ts
|
|
276
|
-
|
|
311
|
+
// before
|
|
312
|
+
actions: { notifyExternal: serverOnly(async (state) => { ... }) }
|
|
313
|
+
// after
|
|
314
|
+
serverActions: { notifyExternal: async ({ state }) => { ... } }
|
|
277
315
|
```
|
|
278
316
|
|
|
279
317
|
### manifest.json(run() を使う場合)
|
package/dist/index.d.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* scenario ディレクトリで `uzu dev` を実行するだけで良い。 子 iframe は既存 online mode
|
|
16
16
|
* (`?server=ws://localhost:<port>` 経路) で dev-server に接続する。
|
|
17
17
|
*/
|
|
18
|
-
export type { PlayScreenMessage, BridgeChannel, BridgeMessage, Seat, SeatKind, Emit, ServerEvent, SeededRandom, GameLogic, GameConfig, SyncConfig, PatchFn, SetFn, Operation, ConnectionState, ConnectionCallbacks, PlayerVoiceState, PlayersChangedMessage, ActionArgs, ActionContext, ActionHandler, ServerActionArgs, UpdateArgs, UpdateContext, EventHandler, EventSubscription,
|
|
19
|
-
export { SERVER_TIME, DEFAULT_ICON_URLS
|
|
18
|
+
export type { PlayScreenMessage, BridgeChannel, BridgeMessage, Seat, SeatKind, Emit, ServerEvent, SeededRandom, GameLogic, GameConfig, SyncConfig, PatchFn, SetFn, Operation, ConnectionState, ConnectionCallbacks, PlayerVoiceState, PlayersChangedMessage, ActionArgs, ActionContext, ActionHandler, ServerActionArgs, SetupArgs, SetupContext, UpdateArgs, UpdateContext, EventHandler, EventSubscription, Deadline, DeadlineArgs, DeadlineContext, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
|
|
19
|
+
export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
|
|
20
20
|
export { serverOnly, isServerOnlyAction } from './server-only.js';
|
|
21
21
|
export { serverNow } from './server-clock.js';
|
|
22
22
|
export { Room } from './room.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { SERVER_TIME, DEFAULT_ICON_URLS
|
|
1
|
+
export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
|
|
2
2
|
export { serverOnly, isServerOnlyAction } from './server-only.js';
|
|
3
3
|
export { serverNow } from './server-clock.js';
|
|
4
4
|
export { Room } from './room.js';
|
|
@@ -15,33 +15,82 @@ export function runLocalServerAction(config) {
|
|
|
15
15
|
}));
|
|
16
16
|
const myId = players[0].id;
|
|
17
17
|
// イベント収集→一括配信(DO と同じパターン)
|
|
18
|
-
// ソロモードでは alarm の代わりに setTimeout
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
18
|
+
// ソロモードでは alarm の代わりに setTimeout で締切に起きる。締切は state から
|
|
19
|
+
// 導出するので、state を変えたら syncWakeup() を通すだけでよい。
|
|
20
|
+
let wakeupTimer = null;
|
|
21
|
+
let wakeupAt = null;
|
|
22
|
+
const nextDeadline = () => {
|
|
23
|
+
if (!logic.deadlines)
|
|
24
|
+
return null;
|
|
25
|
+
let earliest = null;
|
|
26
|
+
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
27
|
+
let at;
|
|
28
|
+
try {
|
|
29
|
+
at = deadline.at({ state });
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (typeof at !== 'number' || !Number.isFinite(at))
|
|
36
|
+
continue;
|
|
37
|
+
if (earliest === null || at < earliest)
|
|
38
|
+
earliest = at;
|
|
39
|
+
}
|
|
40
|
+
return earliest;
|
|
41
|
+
};
|
|
42
|
+
const fireDue = () => {
|
|
43
|
+
wakeupTimer = null;
|
|
44
|
+
wakeupAt = null;
|
|
45
|
+
if (!logic.deadlines)
|
|
46
|
+
return;
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const evts = [];
|
|
49
|
+
const firedKeys = [];
|
|
50
|
+
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
51
|
+
// handler が emit してから throw したときに捨てられるよう、締切ごとに溜める。
|
|
52
|
+
// state を戻したのに音や演出だけ流れると、起きていない出来事が見えてしまう。
|
|
53
|
+
const pending = [];
|
|
54
|
+
let snapshot = null;
|
|
55
|
+
try {
|
|
56
|
+
const at = deadline.at({ state });
|
|
57
|
+
if (typeof at !== 'number' || !Number.isFinite(at) || at > now)
|
|
58
|
+
continue;
|
|
59
|
+
// 期限が来たものだけ複製する。毎周撮ると締切の数だけ state の複製が走る。
|
|
60
|
+
snapshot = structuredClone(state);
|
|
61
|
+
deadline.handler({
|
|
62
|
+
state,
|
|
63
|
+
ctx: { now, random, emit: (name, data) => pending.push({ name, data: data ?? {} }) },
|
|
64
|
+
});
|
|
65
|
+
evts.push(...pending);
|
|
66
|
+
firedKeys.push(key);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// サーバー (DO) 側と同じく、失敗した締切ぶんだけ巻き戻す。
|
|
70
|
+
if (snapshot !== null)
|
|
71
|
+
state = snapshot;
|
|
72
|
+
console.error(`[Deadline] ❌ ${key} で例外`, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
syncWakeup();
|
|
76
|
+
if (firedKeys.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(', ')}`);
|
|
79
|
+
dispatchEvents(evts);
|
|
80
|
+
onState(state, myId);
|
|
81
|
+
};
|
|
82
|
+
const syncWakeup = () => {
|
|
83
|
+
const next = nextDeadline();
|
|
84
|
+
if (next === wakeupAt)
|
|
85
|
+
return;
|
|
86
|
+
if (wakeupTimer)
|
|
87
|
+
clearTimeout(wakeupTimer);
|
|
88
|
+
wakeupTimer = null;
|
|
89
|
+
wakeupAt = next;
|
|
90
|
+
if (next === null)
|
|
91
|
+
return;
|
|
92
|
+
wakeupTimer = setTimeout(fireDue, Math.max(0, next - Date.now()));
|
|
93
|
+
};
|
|
45
94
|
// ソロモードはこのクライアント自身がサーバーなので、先読みという概念が無い。
|
|
46
95
|
// predict の値によらず、確定として 1 回だけ実行する。
|
|
47
96
|
const dispatchEvents = (evts) => {
|
|
@@ -50,7 +99,7 @@ export function runLocalServerAction(config) {
|
|
|
50
99
|
}
|
|
51
100
|
};
|
|
52
101
|
// setRawState で全置換できるよう let。closures は名前参照なので最新束縛を読む。
|
|
53
|
-
let state = logic.setup(players, random);
|
|
102
|
+
let state = logic.setup({ seats: players, ctx: { random, now: Date.now() } });
|
|
54
103
|
let tick = 0;
|
|
55
104
|
const playerInputs = {};
|
|
56
105
|
// Action 処理。`actions` は同期実行で `sendAction()` 直後の同期 onState を保証する
|
|
@@ -80,7 +129,7 @@ export function runLocalServerAction(config) {
|
|
|
80
129
|
state,
|
|
81
130
|
payload: payload ?? {},
|
|
82
131
|
playerId: myId,
|
|
83
|
-
ctx: { now, emit: plainEmit
|
|
132
|
+
ctx: { now, emit: plainEmit },
|
|
84
133
|
});
|
|
85
134
|
}
|
|
86
135
|
catch (err) {
|
|
@@ -88,6 +137,7 @@ export function runLocalServerAction(config) {
|
|
|
88
137
|
return;
|
|
89
138
|
}
|
|
90
139
|
dispatchEvents(plainEvents);
|
|
140
|
+
syncWakeup();
|
|
91
141
|
onState(state, myId);
|
|
92
142
|
}
|
|
93
143
|
if (!server)
|
|
@@ -98,7 +148,7 @@ export function runLocalServerAction(config) {
|
|
|
98
148
|
state,
|
|
99
149
|
payload: payload ?? {},
|
|
100
150
|
playerId: myId,
|
|
101
|
-
ctx: { tick, random, now, emit: serverEmit
|
|
151
|
+
ctx: { tick, random, now, emit: serverEmit },
|
|
102
152
|
});
|
|
103
153
|
}
|
|
104
154
|
catch (err) {
|
|
@@ -106,10 +156,12 @@ export function runLocalServerAction(config) {
|
|
|
106
156
|
return;
|
|
107
157
|
}
|
|
108
158
|
dispatchEvents(serverEvents);
|
|
159
|
+
syncWakeup();
|
|
109
160
|
onState(state, myId);
|
|
110
161
|
})();
|
|
111
162
|
};
|
|
112
163
|
inputs(dispatchAction);
|
|
164
|
+
syncWakeup();
|
|
113
165
|
onState(state, myId);
|
|
114
166
|
// Tick ループ(tickRate > 0 の場合のみ、DO と同じ)
|
|
115
167
|
if (tickRate > 0) {
|
|
@@ -123,7 +175,6 @@ export function runLocalServerAction(config) {
|
|
|
123
175
|
random,
|
|
124
176
|
tick,
|
|
125
177
|
now: Date.now(),
|
|
126
|
-
...makeScheduleCtx(),
|
|
127
178
|
emit: tickEmit,
|
|
128
179
|
playerInputs,
|
|
129
180
|
},
|
|
@@ -136,6 +187,7 @@ export function runLocalServerAction(config) {
|
|
|
136
187
|
}
|
|
137
188
|
tick++;
|
|
138
189
|
dispatchEvents(tickEvents);
|
|
190
|
+
syncWakeup();
|
|
139
191
|
onState(state, myId);
|
|
140
192
|
}, 1000 / tickRate);
|
|
141
193
|
}
|
|
@@ -143,6 +195,7 @@ export function runLocalServerAction(config) {
|
|
|
143
195
|
getRawState: () => state,
|
|
144
196
|
setRawState: async (next) => {
|
|
145
197
|
state = next;
|
|
198
|
+
syncWakeup();
|
|
146
199
|
onState(state, myId);
|
|
147
200
|
},
|
|
148
201
|
mergeRawState: async (patch) => {
|
|
@@ -41,18 +41,6 @@ export function createOptimisticActionClient(config) {
|
|
|
41
41
|
};
|
|
42
42
|
/** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
|
|
43
43
|
const eventKey = (e) => `${e.name}\u0000${JSON.stringify(e.data ?? {})}`;
|
|
44
|
-
/**
|
|
45
|
-
* 先読み用の schedule。予約自体はサーバーだけが持つので**何もしない**が、
|
|
46
|
-
* 戻り値 (確定した絶対時刻) は返す。シナリオは戻り値を表示用の endsAt として
|
|
47
|
-
* state に入れるので、ここで undefined を返すと先読み中だけタイマーが消える。
|
|
48
|
-
*
|
|
49
|
-
* 型には schedule があるのに実体を渡さないと `ctx.schedule is not a function` で
|
|
50
|
-
* 先読みが丸ごと落ちる (predicted な action から呼ばれた瞬間)。
|
|
51
|
-
*/
|
|
52
|
-
const predictedScheduleCtx = (now) => ({
|
|
53
|
-
schedule: (options) => options.at != null ? Number(options.at) : now + Number(options.after ?? 0) * 1000,
|
|
54
|
-
unschedule: () => { },
|
|
55
|
-
});
|
|
56
44
|
/**
|
|
57
45
|
* サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
|
|
58
46
|
*
|
|
@@ -102,7 +90,7 @@ export function createOptimisticActionClient(config) {
|
|
|
102
90
|
state: displayState,
|
|
103
91
|
payload,
|
|
104
92
|
playerId,
|
|
105
|
-
ctx: { now, emit: noopEmit
|
|
93
|
+
ctx: { now, emit: noopEmit },
|
|
106
94
|
}));
|
|
107
95
|
i++;
|
|
108
96
|
}
|
|
@@ -159,7 +147,7 @@ export function createOptimisticActionClient(config) {
|
|
|
159
147
|
state: target,
|
|
160
148
|
payload: payload ?? {},
|
|
161
149
|
playerId,
|
|
162
|
-
ctx: { now, emit: predictEmit
|
|
150
|
+
ctx: { now, emit: predictEmit },
|
|
163
151
|
}));
|
|
164
152
|
pendingActions.push({ seq, action: type, payload: payload ?? {}, now });
|
|
165
153
|
onState(target, playerId);
|
|
@@ -28,14 +28,6 @@ const makeLogic = () => ({
|
|
|
28
28
|
state.stampedAt = ctx.now;
|
|
29
29
|
ctx.emit('moved', {});
|
|
30
30
|
},
|
|
31
|
-
// 先読みでも ctx.schedule を呼ぶ action。型にはあるので呼べてしまう。
|
|
32
|
-
startTimer: ({ state, ctx }) => {
|
|
33
|
-
state.stampedAt = ctx.schedule({
|
|
34
|
-
key: 'timer',
|
|
35
|
-
after: 60,
|
|
36
|
-
action: 'timer.fire',
|
|
37
|
-
});
|
|
38
|
-
},
|
|
39
31
|
// 予測の結果によって別の event を出す (予測ミスの再現用)
|
|
40
32
|
guess: ({ state, payload, ctx }) => {
|
|
41
33
|
state.moves += 1;
|
|
@@ -416,23 +408,6 @@ describe('createOptimisticActionClient', () => {
|
|
|
416
408
|
expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
|
|
417
409
|
expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
|
|
418
410
|
});
|
|
419
|
-
/**
|
|
420
|
-
* IMPORTANT: 型に schedule があるので、先読みされる action からも呼べてしまう。
|
|
421
|
-
* 実体を渡していないと `ctx.schedule is not a function` で先読みが丸ごと落ちる。
|
|
422
|
-
*
|
|
423
|
-
* 予約自体はサーバーだけが持つので先読みでは何もしないが、戻り値 (絶対時刻) は
|
|
424
|
-
* 返す必要がある。シナリオはそれを表示用の endsAt として state に入れるため、
|
|
425
|
-
* undefined を返すと先読み中だけタイマーが消える。
|
|
426
|
-
*/
|
|
427
|
-
it('先読みでも ctx.schedule が呼べて絶対時刻を返す', () => {
|
|
428
|
-
const { client, states, sent } = setup();
|
|
429
|
-
client.send('startTimer');
|
|
430
|
-
// 先読みが落ちていたら pending に積まれず state も変わらない
|
|
431
|
-
expect(sent).toEqual([{ action: 'startTimer', seq: 1 }]);
|
|
432
|
-
const stamped = latest(states).stampedAt;
|
|
433
|
-
expect(stamped).toBeGreaterThanOrEqual(Date.now() + 60000 - 5000);
|
|
434
|
-
expect(stamped).toBeLessThanOrEqual(Date.now() + 60000 + 5000);
|
|
435
|
-
});
|
|
436
411
|
/**
|
|
437
412
|
* IMPORTANT: 再適用 (reconciliation でのやり直し) では初回予測時の now を使い回す。
|
|
438
413
|
*
|
package/dist/types.d.ts
CHANGED
|
@@ -104,45 +104,50 @@ export interface ActionContext {
|
|
|
104
104
|
*/
|
|
105
105
|
now: number;
|
|
106
106
|
emit: Emit;
|
|
107
|
-
/** 予約はサーバーだけが持つ。先読みでは戻り値を計算するだけで、予約も取り消しもしない。 */
|
|
108
|
-
schedule(options: ScheduleOptions): number;
|
|
109
|
-
unschedule(key: string): void;
|
|
110
107
|
}
|
|
111
|
-
/**
|
|
112
|
-
export interface
|
|
113
|
-
/**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
108
|
+
/** `deadlines` handler の実行文脈。サーバーでしか走らないので tick 以外を渡せる。 */
|
|
109
|
+
export interface DeadlineContext {
|
|
110
|
+
/** 発火時刻 (ms)。サーバー専用なので常に正確。 */
|
|
111
|
+
now: number;
|
|
112
|
+
random: SeededRandom;
|
|
113
|
+
emit: Emit;
|
|
114
|
+
}
|
|
115
|
+
/** `deadlines` handler の引数。 */
|
|
116
|
+
export interface DeadlineArgs<S> {
|
|
117
|
+
state: S;
|
|
118
|
+
ctx: DeadlineContext;
|
|
121
119
|
}
|
|
122
120
|
/**
|
|
123
|
-
*
|
|
121
|
+
* サーバー権威の締切。
|
|
122
|
+
*
|
|
123
|
+
* 「state のこの時刻を過ぎたらこれをする」を宣言する。サーバーが `at` の最も早いものに
|
|
124
|
+
* 合わせて自分で起き、過ぎた締切の `handler` を呼ぶ。`tickRate` で毎秒ポーリングする
|
|
125
|
+
* 必要が無くなり、その間 Durable Object は hibernate できる。
|
|
124
126
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* その間 Durable Object は hibernate できる。
|
|
127
|
+
* 締切は state から導出するので、予約を張り替える処理を書かなくてよい。action が
|
|
128
|
+
* throw して state が巻き戻れば、締切も一緒に巻き戻る。
|
|
128
129
|
*/
|
|
129
|
-
export interface
|
|
130
|
+
export interface Deadline<S> {
|
|
130
131
|
/**
|
|
131
|
-
*
|
|
132
|
+
* 締切の絶対時刻 (ms)。締切が無いときは null / undefined。
|
|
133
|
+
*
|
|
134
|
+
* `state.timer?.endsAt` のような optional chain の結果をそのまま返せるよう
|
|
135
|
+
* undefined も受ける。数値以外は「締切なし」として同じに扱う。
|
|
132
136
|
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
137
|
+
* state が変わるたびに呼ばれるので、state だけから決まる軽い関数にすること。
|
|
138
|
+
* ここで実時刻や乱数を読むと、呼ばれるたびに答えが変わって予約が暴れる。
|
|
135
139
|
*/
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
at(args: {
|
|
141
|
+
state: S;
|
|
142
|
+
}): number | null | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* `at` の時刻を過ぎたときにサーバーで呼ばれる。
|
|
145
|
+
*
|
|
146
|
+
* 発火は at-least-once だが、SDK が発火直前に `at` を評価し直して過ぎているものだけを
|
|
147
|
+
* 呼ぶので、handler 側で二重発火を弾く必要は無い。
|
|
148
|
+
*/
|
|
149
|
+
handler(args: DeadlineArgs<S>): void;
|
|
138
150
|
}
|
|
139
|
-
/**
|
|
140
|
-
* 予約から発火した action に渡る playerId。
|
|
141
|
-
*
|
|
142
|
-
* 送信者がいないので、人間の操作と区別するための定数。
|
|
143
|
-
* `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
|
|
144
|
-
*/
|
|
145
|
-
export declare const SCHEDULED_ACTOR = "__scheduled";
|
|
146
151
|
/** `serverActions` handler の実行文脈。サーバーでしか走らないので tick と乱数を渡せる。 */
|
|
147
152
|
export interface ServerActionContext {
|
|
148
153
|
tick: number;
|
|
@@ -150,8 +155,6 @@ export interface ServerActionContext {
|
|
|
150
155
|
/** サーバーの実時刻 (ms)。同じ dispatch の `actions` に渡る `ctx.now` と同一値。 */
|
|
151
156
|
now: number;
|
|
152
157
|
emit: Emit;
|
|
153
|
-
schedule(options: ScheduleOptions): number;
|
|
154
|
-
unschedule(key: string): void;
|
|
155
158
|
}
|
|
156
159
|
/** @deprecated `ServerActionContext` を使う。 */
|
|
157
160
|
export type ServerOnlyActionContext = ServerActionContext;
|
|
@@ -191,8 +194,6 @@ export interface UpdateContext {
|
|
|
191
194
|
tick: number;
|
|
192
195
|
/** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
|
|
193
196
|
now: number;
|
|
194
|
-
schedule(options: ScheduleOptions): number;
|
|
195
|
-
unschedule(key: string): void;
|
|
196
197
|
emit: Emit;
|
|
197
198
|
playerInputs: Record<string, Record<string, any>>;
|
|
198
199
|
}
|
|
@@ -200,12 +201,27 @@ export interface UpdateArgs<S> {
|
|
|
200
201
|
state: S;
|
|
201
202
|
ctx: UpdateContext;
|
|
202
203
|
}
|
|
203
|
-
|
|
204
|
+
/**
|
|
205
|
+
* `setup()` の実行文脈。サーバーでしか走らないので実時刻をそのまま渡せる。
|
|
206
|
+
*
|
|
207
|
+
* `emit` は無い。まだ誰も購読していない時点なので、鳴らしても届かない。
|
|
208
|
+
*/
|
|
209
|
+
export interface SetupContext {
|
|
210
|
+
random: SeededRandom;
|
|
211
|
+
/** サーバーの実時刻 (ms)。setup はサーバーでしか走らないので常に正確。 */
|
|
212
|
+
now: number;
|
|
213
|
+
}
|
|
214
|
+
/** `setup()` の引数。 */
|
|
215
|
+
export interface SetupArgs {
|
|
204
216
|
/**
|
|
205
217
|
* seats には kind !== 'player' の席 (spectator / admin) も含まれる。
|
|
206
218
|
* ゲームの配役は kind === 'player' (または kind 省略) だけを対象にすること。
|
|
207
219
|
*/
|
|
208
|
-
|
|
220
|
+
seats: Seat[];
|
|
221
|
+
ctx: SetupContext;
|
|
222
|
+
}
|
|
223
|
+
export interface GameLogic<S> {
|
|
224
|
+
setup(args: SetupArgs): S;
|
|
209
225
|
/**
|
|
210
226
|
* クライアント先読みとサーバーの両方で走る handler。決定的でなければならない。
|
|
211
227
|
*
|
|
@@ -223,6 +239,13 @@ export interface GameLogic<S> {
|
|
|
223
239
|
*/
|
|
224
240
|
serverActions?: Record<string, ServerActionHandler<S>>;
|
|
225
241
|
update(args: UpdateArgs<S>): void;
|
|
242
|
+
/**
|
|
243
|
+
* state 由来の締切。サーバーが `at` の時刻に自分で起きて `handler` を呼ぶ。
|
|
244
|
+
*
|
|
245
|
+
* 時刻をきっかけに何かを起こすなら `tickRate` で毎秒ポーリングせずこちらを使う。
|
|
246
|
+
* ポーリング中は Durable Object が hibernate できない。
|
|
247
|
+
*/
|
|
248
|
+
deadlines?: Record<string, Deadline<S>>;
|
|
226
249
|
tickRate?: number;
|
|
227
250
|
}
|
|
228
251
|
export interface GameConfig<S> extends ConnectionCallbacks {
|
package/dist/types.js
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 予約から発火した action に渡る playerId。
|
|
3
|
-
*
|
|
4
|
-
* 送信者がいないので、人間の操作と区別するための定数。
|
|
5
|
-
* `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
|
|
6
|
-
*/
|
|
7
|
-
export const SCHEDULED_ACTOR = '__scheduled';
|
|
8
1
|
/** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
|
|
9
2
|
export const SERVER_TIME = '__SERVER_TIME__';
|
|
10
3
|
/** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
|