@uzuhq/code-sdk 0.5.0 → 0.7.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/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/run/local-server-action.js +55 -4
- package/dist/run/local-server-action.test.js +30 -19
- package/dist/run/optimistic-action-client.d.ts +16 -10
- package/dist/run/optimistic-action-client.js +105 -21
- package/dist/run/optimistic-action-client.test.js +279 -56
- package/dist/run/server-action.js +4 -4
- package/dist/server-clock.d.ts +29 -0
- package/dist/server-clock.js +40 -0
- package/dist/types.d.ts +121 -12
- package/dist/types.js +7 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -15,9 +15,10 @@
|
|
|
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, ActionContext, ActionHandler, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
|
|
19
|
-
export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
|
|
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, ScheduleOptions, Scheduler, ServerActionContext, ServerActionHandler, ServerOnlyAction, ServerOnlyActionContext, ServerOnlyActionHandlerFn, } from './types.js';
|
|
19
|
+
export { SERVER_TIME, DEFAULT_ICON_URLS, SCHEDULED_ACTOR } from './types.js';
|
|
20
20
|
export { serverOnly, isServerOnlyAction } from './server-only.js';
|
|
21
|
+
export { serverNow } from './server-clock.js';
|
|
21
22
|
export { Room } from './room.js';
|
|
22
23
|
export type { RoomLike } from './room.js';
|
|
23
24
|
export { ReconnectableWebSocket } from './reconnectable-ws.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
|
|
1
|
+
export { SERVER_TIME, DEFAULT_ICON_URLS, SCHEDULED_ACTOR } from './types.js';
|
|
2
2
|
export { serverOnly, isServerOnlyAction } from './server-only.js';
|
|
3
|
+
export { serverNow } from './server-clock.js';
|
|
3
4
|
export { Room } from './room.js';
|
|
4
5
|
export { ReconnectableWebSocket } from './reconnectable-ws.js';
|
|
5
6
|
export { SeededRandomImpl } from './random.js';
|
|
@@ -15,9 +15,38 @@ export function runLocalServerAction(config) {
|
|
|
15
15
|
}));
|
|
16
16
|
const myId = players[0].id;
|
|
17
17
|
// イベント収集→一括配信(DO と同じパターン)
|
|
18
|
+
// ソロモードでは alarm の代わりに setTimeout で予約を実現する。
|
|
19
|
+
// key ごとに 1 件へ畳む点は本番と同じ。
|
|
20
|
+
const scheduled = new Map();
|
|
21
|
+
const makeScheduleCtx = () => ({
|
|
22
|
+
schedule(options) {
|
|
23
|
+
const at = options.at ?? Date.now() + (options.after ?? 0) * 1000;
|
|
24
|
+
const prev = scheduled.get(options.key);
|
|
25
|
+
if (prev)
|
|
26
|
+
clearTimeout(prev);
|
|
27
|
+
scheduled.set(options.key, setTimeout(() => {
|
|
28
|
+
scheduled.delete(options.key);
|
|
29
|
+
try {
|
|
30
|
+
dispatchAction(options.action, options.payload ?? {});
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.error(`[Scheduler] ❌ key=${options.key}:`, err);
|
|
34
|
+
}
|
|
35
|
+
}, Math.max(0, at - Date.now())));
|
|
36
|
+
return at;
|
|
37
|
+
},
|
|
38
|
+
unschedule(key) {
|
|
39
|
+
const t = scheduled.get(key);
|
|
40
|
+
if (t)
|
|
41
|
+
clearTimeout(t);
|
|
42
|
+
scheduled.delete(key);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
// ソロモードはこのクライアント自身がサーバーなので、先読みという概念が無い。
|
|
46
|
+
// predict の値によらず、確定として 1 回だけ実行する。
|
|
18
47
|
const dispatchEvents = (evts) => {
|
|
19
48
|
for (const e of evts) {
|
|
20
|
-
events?.[e.name]?.(e.data);
|
|
49
|
+
events?.[e.name]?.handler(e.data);
|
|
21
50
|
}
|
|
22
51
|
};
|
|
23
52
|
// setRawState で全置換できるよう let。closures は名前参照なので最新束縛を読む。
|
|
@@ -43,9 +72,16 @@ export function runLocalServerAction(config) {
|
|
|
43
72
|
const serverEvents = [];
|
|
44
73
|
const plainEmit = (name, data) => plainEvents.push({ name, data: data ?? {} });
|
|
45
74
|
const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
|
|
75
|
+
// ソロモードはこのクライアント自身がサーバーなので、実時刻がそのまま正となる。
|
|
76
|
+
const now = Date.now();
|
|
46
77
|
if (plain && !legacyServerOnly) {
|
|
47
78
|
try {
|
|
48
|
-
plain(
|
|
79
|
+
plain({
|
|
80
|
+
state,
|
|
81
|
+
payload: payload ?? {},
|
|
82
|
+
playerId: myId,
|
|
83
|
+
ctx: { now, emit: plainEmit, ...makeScheduleCtx() },
|
|
84
|
+
});
|
|
49
85
|
}
|
|
50
86
|
catch (err) {
|
|
51
87
|
console.warn('[SDK LocalServerAction] Action error:', err);
|
|
@@ -58,7 +94,12 @@ export function runLocalServerAction(config) {
|
|
|
58
94
|
return;
|
|
59
95
|
void (async () => {
|
|
60
96
|
try {
|
|
61
|
-
await server(
|
|
97
|
+
await server({
|
|
98
|
+
state,
|
|
99
|
+
payload: payload ?? {},
|
|
100
|
+
playerId: myId,
|
|
101
|
+
ctx: { tick, random, now, emit: serverEmit, ...makeScheduleCtx() },
|
|
102
|
+
});
|
|
62
103
|
}
|
|
63
104
|
catch (err) {
|
|
64
105
|
console.warn('[SDK LocalServerAction] Action error:', err);
|
|
@@ -76,7 +117,17 @@ export function runLocalServerAction(config) {
|
|
|
76
117
|
const tickEvents = [];
|
|
77
118
|
const tickEmit = (name, data) => tickEvents.push({ name, data: data ?? {} });
|
|
78
119
|
try {
|
|
79
|
-
logic.update(
|
|
120
|
+
logic.update({
|
|
121
|
+
state,
|
|
122
|
+
ctx: {
|
|
123
|
+
random,
|
|
124
|
+
tick,
|
|
125
|
+
now: Date.now(),
|
|
126
|
+
...makeScheduleCtx(),
|
|
127
|
+
emit: tickEmit,
|
|
128
|
+
playerInputs,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
80
131
|
}
|
|
81
132
|
catch (err) {
|
|
82
133
|
console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
|
|
@@ -26,8 +26,18 @@ const run = (logic) => {
|
|
|
26
26
|
send = sendAction;
|
|
27
27
|
},
|
|
28
28
|
events: {
|
|
29
|
-
moved:
|
|
30
|
-
|
|
29
|
+
moved: {
|
|
30
|
+
predict: true,
|
|
31
|
+
handler: () => {
|
|
32
|
+
fired.push('moved');
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
charged: {
|
|
36
|
+
predict: false,
|
|
37
|
+
handler: () => {
|
|
38
|
+
fired.push('charged');
|
|
39
|
+
},
|
|
40
|
+
},
|
|
31
41
|
},
|
|
32
42
|
};
|
|
33
43
|
runLocalServerAction(config);
|
|
@@ -45,9 +55,9 @@ describe('runLocalServerAction', () => {
|
|
|
45
55
|
it('actions だけなら同期で state と events が確定する', () => {
|
|
46
56
|
const h = run(baseLogic({
|
|
47
57
|
actions: {
|
|
48
|
-
move: (state,
|
|
58
|
+
move: ({ state, ctx }) => {
|
|
49
59
|
state.moves += 1;
|
|
50
|
-
emit('moved');
|
|
60
|
+
ctx.emit('moved');
|
|
51
61
|
},
|
|
52
62
|
},
|
|
53
63
|
}));
|
|
@@ -59,10 +69,10 @@ describe('runLocalServerAction', () => {
|
|
|
59
69
|
it('serverActions だけなら await 後に反映される', async () => {
|
|
60
70
|
const h = run(baseLogic({
|
|
61
71
|
serverActions: {
|
|
62
|
-
notifyExternal: async (state,
|
|
72
|
+
notifyExternal: async ({ state, ctx }) => {
|
|
63
73
|
await tick();
|
|
64
74
|
state.charged += 1;
|
|
65
|
-
emit('charged');
|
|
75
|
+
ctx.emit('charged');
|
|
66
76
|
},
|
|
67
77
|
},
|
|
68
78
|
}));
|
|
@@ -81,13 +91,13 @@ describe('runLocalServerAction', () => {
|
|
|
81
91
|
const order = [];
|
|
82
92
|
const h = run(baseLogic({
|
|
83
93
|
actions: {
|
|
84
|
-
move: (state) => {
|
|
94
|
+
move: ({ state }) => {
|
|
85
95
|
order.push('actions');
|
|
86
96
|
state.moves += 1;
|
|
87
97
|
},
|
|
88
98
|
},
|
|
89
99
|
serverActions: {
|
|
90
|
-
move: (state,
|
|
100
|
+
move: ({ state, ctx }) => {
|
|
91
101
|
order.push('serverActions');
|
|
92
102
|
// actions の結果が見えている
|
|
93
103
|
expect(state.moves).toBe(1);
|
|
@@ -114,16 +124,16 @@ describe('runLocalServerAction', () => {
|
|
|
114
124
|
it('actions の events は serverActions の await を待たない', async () => {
|
|
115
125
|
const h = run(baseLogic({
|
|
116
126
|
actions: {
|
|
117
|
-
move: (state,
|
|
127
|
+
move: ({ state, ctx }) => {
|
|
118
128
|
state.moves += 1;
|
|
119
|
-
emit('moved');
|
|
129
|
+
ctx.emit('moved');
|
|
120
130
|
},
|
|
121
131
|
},
|
|
122
132
|
serverActions: {
|
|
123
|
-
move: async (state,
|
|
133
|
+
move: async ({ state, ctx }) => {
|
|
124
134
|
await tick();
|
|
125
135
|
state.charged += 1;
|
|
126
|
-
emit('charged');
|
|
136
|
+
ctx.emit('charged');
|
|
127
137
|
},
|
|
128
138
|
},
|
|
129
139
|
}));
|
|
@@ -161,9 +171,9 @@ describe('runLocalServerAction', () => {
|
|
|
161
171
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
162
172
|
const h = run(baseLogic({
|
|
163
173
|
actions: {
|
|
164
|
-
move: (state,
|
|
174
|
+
move: ({ state, ctx }) => {
|
|
165
175
|
state.moves += 1;
|
|
166
|
-
emit('moved');
|
|
176
|
+
ctx.emit('moved');
|
|
167
177
|
},
|
|
168
178
|
},
|
|
169
179
|
serverActions: {
|
|
@@ -186,13 +196,14 @@ describe('runLocalServerAction', () => {
|
|
|
186
196
|
it('actions に入った serverOnly() は server 側として実行される', async () => {
|
|
187
197
|
const logic = baseLogic({
|
|
188
198
|
actions: {
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
199
|
+
// 検証対象は `__serverOnly` brand の判定であって、旧シグネチャの互換ではない。
|
|
200
|
+
// handler 自体は現行の呼び出し形で書いている (旧形式の logic.js は現行テンプレでは
|
|
201
|
+
// 動かない。公開済みゲームが無い前提で互換を切っている)。
|
|
202
|
+
// @ts-expect-error actions に serverOnly() を入れるのは型違反だが、brand 判定を試す
|
|
203
|
+
legacy: serverOnly(async ({ state, ctx }) => {
|
|
193
204
|
await tick();
|
|
194
205
|
state.charged += ctx.tick + 1;
|
|
195
|
-
emit('charged');
|
|
206
|
+
ctx.emit('charged');
|
|
196
207
|
}),
|
|
197
208
|
},
|
|
198
209
|
});
|
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - `send(type, payload)`: `logic.actions` の handler を同期で先行実行し pending キューへ。
|
|
12
12
|
* `logic.serverActions` は先読みせず transport にだけ送る。
|
|
13
|
-
* - `applyState(state, { ack, from, events
|
|
14
|
-
* - `applyDelta(patches, { ack, from, events
|
|
13
|
+
* - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
|
|
14
|
+
* - `applyDelta(patches, { ack, from, events })`: JSON Patch を受信した時に呼ぶ
|
|
15
15
|
* (適用失敗時は false を返すので transport 側でフル state を再要求する)。
|
|
16
16
|
* - `rollback(seq)`: `__action_error` 受信時に該当 action を pending から除去して再適用。
|
|
17
17
|
* - `reset(state)`: 再接続後の state 復元用 (pending を全クリア)。
|
|
18
18
|
*/
|
|
19
|
-
import type { GameLogic } from '../types.js';
|
|
19
|
+
import type { GameLogic, EventSubscription } from '../types.js';
|
|
20
20
|
import type { Operation } from '../json-patch.js';
|
|
21
21
|
export interface EventEntry {
|
|
22
22
|
name: string;
|
|
@@ -25,21 +25,22 @@ export interface EventEntry {
|
|
|
25
25
|
export interface ConfirmOptions {
|
|
26
26
|
/** transport から受け取った action ack (確定された pending action の seq) */
|
|
27
27
|
ack?: number;
|
|
28
|
-
/** ack の送信元 player id
|
|
28
|
+
/** ack の送信元 player id */
|
|
29
29
|
from?: string;
|
|
30
|
-
/** `logic.actions` 由来の events。自分の ack なら先読み時に発火済みなので skip する */
|
|
31
|
-
events?: EventEntry[];
|
|
32
30
|
/**
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
31
|
+
* サーバーが確定させた events。`actions` 由来と `serverActions` 由来を区別しない
|
|
32
|
+
* 1 本のリスト。
|
|
33
|
+
*
|
|
34
|
+
* 先読みで既に配信済みのものはクライアント側で差し引く (pending の `fired` 記録と
|
|
35
|
+
* 突き合わせる)。サーバーが袋を分ける必要はない。
|
|
35
36
|
*/
|
|
36
|
-
|
|
37
|
+
events?: EventEntry[];
|
|
37
38
|
}
|
|
38
39
|
export interface OptimisticActionClientConfig<S> {
|
|
39
40
|
logic: GameLogic<S>;
|
|
40
41
|
playerId: string;
|
|
41
42
|
onState: (state: S, playerId: string) => void;
|
|
42
|
-
events?: Record<string,
|
|
43
|
+
events?: Record<string, EventSubscription>;
|
|
43
44
|
/** action を transport に流すコールバック */
|
|
44
45
|
sendAction: (msg: {
|
|
45
46
|
action: string;
|
|
@@ -50,6 +51,11 @@ export interface OptimisticActionClientConfig<S> {
|
|
|
50
51
|
export interface OptimisticActionClient<S> {
|
|
51
52
|
/** input から呼ばれる action dispatch (楽観更新 + transport 送信) */
|
|
52
53
|
send(type: string, payload?: any): void;
|
|
54
|
+
/**
|
|
55
|
+
* サーバーが打刻した時刻の観測値を渡してクロックオフセットを更新する。
|
|
56
|
+
* transport が受信した全メッセージで呼んでよい (serverTime を持たないものは無視される)。
|
|
57
|
+
*/
|
|
58
|
+
observeServerTime(serverTime: number | undefined): void;
|
|
53
59
|
/** 仮想サーバー / DO からフル state を受信した時に呼ぶ */
|
|
54
60
|
applyState(state: S, options?: ConfirmOptions): void;
|
|
55
61
|
/** DO から JSON Patch delta を受信した時に呼ぶ。適用失敗時は false (transport で再要求) */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { applyPatch } from '../json-patch.js';
|
|
2
2
|
import { isServerOnlyAction } from '../server-only.js';
|
|
3
3
|
import { runPredicted } from '../dev-prediction-traps.js';
|
|
4
|
+
import { observeServerTime, serverNow } from '../server-clock.js';
|
|
4
5
|
export function createOptimisticActionClient(config) {
|
|
5
6
|
const { logic, playerId, onState, events, sendAction } = config;
|
|
6
7
|
/** サーバー確定 state (楽観的更新のベース) */
|
|
@@ -9,17 +10,71 @@ export function createOptimisticActionClient(config) {
|
|
|
9
10
|
let displayState = null;
|
|
10
11
|
/** クライアント側の action 通番 */
|
|
11
12
|
let actionSeq = 0;
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* 送信済みだがサーバー未確認の action キュー。
|
|
15
|
+
* `now` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
|
|
16
|
+
*/
|
|
13
17
|
const pendingActions = [];
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* 先読みで実行済みの events。
|
|
20
|
+
*
|
|
21
|
+
* IMPORTANT: pending action ごとではなくクライアント単位で持つ。二重実行は「自分の
|
|
22
|
+
* action の ack」だけでなく「他プレイヤーの action のブロードキャスト」でも起きるため
|
|
23
|
+
* (A と B が同時に同じ行送りを撃つと、B は自分の先読みで実行済みなのに A 由来の
|
|
24
|
+
* 配信でもう一度実行してしまう)。どの配信が来ても、まずここと突き合わせる。
|
|
25
|
+
*
|
|
26
|
+
* 記録には「どの action の先読みで実行したか」(seq) を持たせる。その action が ack
|
|
27
|
+
* された時点で引き当てられていない記録は「予測したが実際には起きなかった出来事」なので
|
|
28
|
+
* 捨てる。seq を持たせずに「pending が空になったら捨てる」だけにすると、別の action が
|
|
29
|
+
* 未確定な間ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回
|
|
30
|
+
* 握り潰してしまう。
|
|
31
|
+
*
|
|
32
|
+
* サーバーは 1 本の接続へ処理順どおりに送るので、他プレイヤーの配信は自分の ack より
|
|
33
|
+
* 必ず先に届く = 引き当てのチャンスは記録が生きている間に必ず来る。
|
|
34
|
+
*/
|
|
35
|
+
let firedPredictions = [];
|
|
17
36
|
/** 再適用時はイベントを発火しない (送信時に既に発火済み) */
|
|
18
37
|
const noopEmit = () => { };
|
|
19
38
|
const dispatchEvents = (evts) => {
|
|
20
|
-
for (const e of evts)
|
|
21
|
-
events?.[e.name]?.(e.data);
|
|
39
|
+
for (const e of evts)
|
|
40
|
+
events?.[e.name]?.handler(e.data);
|
|
41
|
+
};
|
|
42
|
+
/** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
|
|
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
|
+
/**
|
|
57
|
+
* サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
|
|
58
|
+
*
|
|
59
|
+
* - 予測が当たった → 差が空。二重に実行しない
|
|
60
|
+
* - 予測が外れた → サーバー側の正しい event が残り、確定として実行される
|
|
61
|
+
* - 先読みで実行していない (predict: false / serverActions 由来 / 他プレイヤー由来)
|
|
62
|
+
* → そのまま残って実行される
|
|
63
|
+
*/
|
|
64
|
+
const subtractFired = (serverEvts) => {
|
|
65
|
+
if (firedPredictions.length === 0)
|
|
66
|
+
return serverEvts;
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const e of serverEvts) {
|
|
69
|
+
const k = eventKey(e);
|
|
70
|
+
const idx = firedPredictions.findIndex((f) => eventKey(f) === k);
|
|
71
|
+
// 一致したら「先読みで実行済み」なので配信せず、記録も 1 件消費する。
|
|
72
|
+
if (idx >= 0)
|
|
73
|
+
firedPredictions.splice(idx, 1);
|
|
74
|
+
else
|
|
75
|
+
out.push(e);
|
|
22
76
|
}
|
|
77
|
+
return out;
|
|
23
78
|
};
|
|
24
79
|
/**
|
|
25
80
|
* confirmedState をベースに pending actions を再適用して displayState を更新する。
|
|
@@ -31,7 +86,7 @@ export function createOptimisticActionClient(config) {
|
|
|
31
86
|
displayState = structuredClone(confirmedState);
|
|
32
87
|
let i = 0;
|
|
33
88
|
while (i < pendingActions.length) {
|
|
34
|
-
const { action, payload } = pendingActions[i];
|
|
89
|
+
const { action, payload, now } = pendingActions[i];
|
|
35
90
|
const handler = logic.actions[action];
|
|
36
91
|
// 移行期の互換: 旧 serverOnly() を actions に入れたままの logic では、その handler は
|
|
37
92
|
// サーバー専用なので先読みの再適用対象から外す (send 時にも pending へ積んでいない)。
|
|
@@ -43,7 +98,12 @@ export function createOptimisticActionClient(config) {
|
|
|
43
98
|
// publish されてしまう。1 件ごとに直前の確定形から作り直し、成功したものだけ採用する。
|
|
44
99
|
const base = structuredClone(displayState);
|
|
45
100
|
try {
|
|
46
|
-
runPredicted(action, () => handler(
|
|
101
|
+
runPredicted(action, () => handler({
|
|
102
|
+
state: displayState,
|
|
103
|
+
payload,
|
|
104
|
+
playerId,
|
|
105
|
+
ctx: { now, emit: noopEmit, ...predictedScheduleCtx(now) },
|
|
106
|
+
}));
|
|
47
107
|
i++;
|
|
48
108
|
}
|
|
49
109
|
catch {
|
|
@@ -54,20 +114,20 @@ export function createOptimisticActionClient(config) {
|
|
|
54
114
|
onState(displayState, playerId);
|
|
55
115
|
};
|
|
56
116
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
117
|
+
* サーバーからの配信を処理する。
|
|
118
|
+
*
|
|
119
|
+
* 送信元が誰であれ、まず先読みの実行記録と突き合わせる。自分の ack だけを見ていると、
|
|
120
|
+
* 他プレイヤーの action が同じ出来事を起こしたときに二重実行になる。
|
|
59
121
|
*/
|
|
60
|
-
const handleAck = (ack, from, evts
|
|
61
|
-
|
|
62
|
-
if (!isMyAck) {
|
|
63
|
-
dispatchEvents(evts);
|
|
64
|
-
}
|
|
65
|
-
// serverActions 由来は先読みで走っていないので、自分の ack でも必ず配信する。
|
|
66
|
-
dispatchEvents(serverEvts);
|
|
122
|
+
const handleAck = (ack, from, evts) => {
|
|
123
|
+
dispatchEvents(subtractFired(evts));
|
|
67
124
|
if (from === playerId && ack !== undefined) {
|
|
68
125
|
while (pendingActions.length > 0 && pendingActions[0].seq <= ack) {
|
|
69
126
|
pendingActions.shift();
|
|
70
127
|
}
|
|
128
|
+
// 確定した action の記録で引き当てられなかったものは「予測したが実際には
|
|
129
|
+
// 起きなかった出来事」。残すと次に本当に起きたときに握り潰してしまう。
|
|
130
|
+
firedPredictions = firedPredictions.filter((f) => f.seq > ack);
|
|
71
131
|
}
|
|
72
132
|
};
|
|
73
133
|
return {
|
|
@@ -79,12 +139,29 @@ export function createOptimisticActionClient(config) {
|
|
|
79
139
|
const handler = logic.actions[type];
|
|
80
140
|
// callback 内では displayState の narrowing が効かないので const に退避する。
|
|
81
141
|
const target = displayState;
|
|
142
|
+
// サーバーがこのアクションを処理する時刻の推定。再適用でも同じ値を使うので、
|
|
143
|
+
// ここで 1 回だけ確定させて pending に載せる。
|
|
144
|
+
const now = serverNow();
|
|
145
|
+
// predict: true の event だけ先読み時点で実行し、実行したものを記録する。
|
|
146
|
+
const predictEmit = (eventName, data) => {
|
|
147
|
+
const subscription = events?.[eventName];
|
|
148
|
+
if (!subscription?.predict)
|
|
149
|
+
return;
|
|
150
|
+
const payloadData = data ?? {};
|
|
151
|
+
subscription.handler(payloadData);
|
|
152
|
+
firedPredictions.push({ seq, name: eventName, data: payloadData });
|
|
153
|
+
};
|
|
82
154
|
// 旧 serverOnly() が actions に残っている logic では、その handler を先読みすると
|
|
83
155
|
// サーバー専用のはずの副作用がクライアントでも走る。brand を見て弾く。
|
|
84
156
|
if (handler && !isServerOnlyAction(handler) && target !== null) {
|
|
85
157
|
try {
|
|
86
|
-
runPredicted(type, () => handler(
|
|
87
|
-
|
|
158
|
+
runPredicted(type, () => handler({
|
|
159
|
+
state: target,
|
|
160
|
+
payload: payload ?? {},
|
|
161
|
+
playerId,
|
|
162
|
+
ctx: { now, emit: predictEmit, ...predictedScheduleCtx(now) },
|
|
163
|
+
}));
|
|
164
|
+
pendingActions.push({ seq, action: type, payload: payload ?? {}, now });
|
|
88
165
|
onState(target, playerId);
|
|
89
166
|
}
|
|
90
167
|
catch {
|
|
@@ -93,8 +170,12 @@ export function createOptimisticActionClient(config) {
|
|
|
93
170
|
}
|
|
94
171
|
sendAction({ action: type, payload: payload ?? {}, seq });
|
|
95
172
|
},
|
|
173
|
+
observeServerTime(serverTime) {
|
|
174
|
+
// 実体は server-clock.ts (UI 側の serverNow() と同じオフセットを共有する)。
|
|
175
|
+
observeServerTime(serverTime);
|
|
176
|
+
},
|
|
96
177
|
applyState(state, options = {}) {
|
|
97
|
-
handleAck(options.ack, options.from, options.events ?? []
|
|
178
|
+
handleAck(options.ack, options.from, options.events ?? []);
|
|
98
179
|
confirmedState = state;
|
|
99
180
|
reapplyPendingActions();
|
|
100
181
|
},
|
|
@@ -110,7 +191,7 @@ export function createOptimisticActionClient(config) {
|
|
|
110
191
|
if (!ok)
|
|
111
192
|
return false;
|
|
112
193
|
confirmedState = cloned;
|
|
113
|
-
handleAck(options.ack, options.from, options.events ?? []
|
|
194
|
+
handleAck(options.ack, options.from, options.events ?? []);
|
|
114
195
|
reapplyPendingActions();
|
|
115
196
|
return true;
|
|
116
197
|
},
|
|
@@ -118,11 +199,14 @@ export function createOptimisticActionClient(config) {
|
|
|
118
199
|
const idx = pendingActions.findIndex((p) => p.seq === seq);
|
|
119
200
|
if (idx !== -1) {
|
|
120
201
|
pendingActions.splice(idx, 1);
|
|
202
|
+
// rollback した action の先読み記録も捨てる (その出来事は起きなかった)。
|
|
203
|
+
firedPredictions = firedPredictions.filter((f) => f.seq !== seq);
|
|
121
204
|
reapplyPendingActions();
|
|
122
205
|
}
|
|
123
206
|
},
|
|
124
207
|
reset(state) {
|
|
125
208
|
pendingActions.length = 0;
|
|
209
|
+
firedPredictions = [];
|
|
126
210
|
confirmedState = state;
|
|
127
211
|
displayState = structuredClone(state);
|
|
128
212
|
onState(displayState, playerId);
|
|
@@ -2,31 +2,50 @@
|
|
|
2
2
|
* optimistic-action-client.ts の unit test。
|
|
3
3
|
*
|
|
4
4
|
* `logic.actions` (クライアント先読み + サーバーの 2 回実行) と `logic.serverActions`
|
|
5
|
-
* (サーバーのみ 1 回実行) を分離した設計が、events
|
|
6
|
-
*
|
|
5
|
+
* (サーバーのみ 1 回実行) を分離した設計が、events の配信と pending キューの巻き戻しの
|
|
6
|
+
* 両方で正しく閉じることを検証する。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* events はサーバーから 1 本のリストで届き、クライアントが「先読みで自分が配信した分」を
|
|
9
|
+
* 差し引いて残りを確定として発火する。予測が当たれば二重に鳴らず、外れればサーバー側の
|
|
10
|
+
* 正しい event が追って届く。
|
|
11
11
|
*/
|
|
12
12
|
import { describe, expect, it, vi } from 'vitest';
|
|
13
13
|
import { createOptimisticActionClient } from './optimistic-action-client.js';
|
|
14
14
|
import { serverOnly } from '../server-only.js';
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { resetServerTimeOffset } from '../server-clock.js';
|
|
16
|
+
/**
|
|
17
|
+
* `serverOnly()` の brand が付いた handler。`actions` に入っていても先読みされないこと
|
|
18
|
+
* (brand 判定が効くこと) を確かめる。旧シグネチャの互換は検証対象ではない。
|
|
19
|
+
*/
|
|
20
|
+
const legacyServerOnly = serverOnly(({ state }) => {
|
|
17
21
|
state.charged += 1000;
|
|
18
22
|
});
|
|
19
23
|
const makeLogic = () => ({
|
|
20
|
-
setup: () => ({ moves: 0, charged: 0 }),
|
|
24
|
+
setup: () => ({ moves: 0, charged: 0, stampedAt: 0 }),
|
|
21
25
|
actions: {
|
|
22
|
-
move: (state) => {
|
|
26
|
+
move: ({ state, ctx }) => {
|
|
27
|
+
state.moves += 1;
|
|
28
|
+
state.stampedAt = ctx.now;
|
|
29
|
+
ctx.emit('moved', {});
|
|
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
|
+
// 予測の結果によって別の event を出す (予測ミスの再現用)
|
|
40
|
+
guess: ({ state, payload, ctx }) => {
|
|
23
41
|
state.moves += 1;
|
|
42
|
+
ctx.emit(payload?.willEmit ?? 'accepted', payload?.data ?? {});
|
|
24
43
|
},
|
|
25
44
|
boom: () => {
|
|
26
45
|
throw new Error('always fails');
|
|
27
46
|
},
|
|
28
47
|
// 途中まで state を書き換えてから throw する (部分ミューテーションの検証用)
|
|
29
|
-
partial: (state) => {
|
|
48
|
+
partial: ({ state }) => {
|
|
30
49
|
state.moves += 1;
|
|
31
50
|
throw new Error('fails after mutating');
|
|
32
51
|
},
|
|
@@ -36,11 +55,12 @@ const makeLogic = () => ({
|
|
|
36
55
|
},
|
|
37
56
|
serverActions: {
|
|
38
57
|
// move と同名 = 同じ action の「サーバーだけで走る続き」
|
|
39
|
-
move: (state,
|
|
58
|
+
move: ({ state, ctx }) => {
|
|
40
59
|
state.charged += ctx.tick;
|
|
60
|
+
ctx.emit('charged', {});
|
|
41
61
|
},
|
|
42
62
|
// actions に無い名前 = 先読みされない action (旧 serverOnly 相当)
|
|
43
|
-
notifyExternal: (state) => {
|
|
63
|
+
notifyExternal: ({ state }) => {
|
|
44
64
|
state.charged += 100;
|
|
45
65
|
},
|
|
46
66
|
},
|
|
@@ -49,6 +69,8 @@ const makeLogic = () => ({
|
|
|
49
69
|
/** 直近の onState 通知。tsconfig の lib が Array#at 未対応なので添字で取る。 */
|
|
50
70
|
const latest = (states) => states[states.length - 1];
|
|
51
71
|
const setup = () => {
|
|
72
|
+
// サーバー時刻オフセットは module 単位で共有されるので、テスト間で持ち越さない。
|
|
73
|
+
resetServerTimeOffset();
|
|
52
74
|
const sent = [];
|
|
53
75
|
const fired = [];
|
|
54
76
|
const states = [];
|
|
@@ -57,12 +79,42 @@ const setup = () => {
|
|
|
57
79
|
playerId: 'me',
|
|
58
80
|
onState: (s) => states.push(structuredClone(s)),
|
|
59
81
|
events: {
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
// predict: true = 先読み時点で実行する
|
|
83
|
+
moved: {
|
|
84
|
+
predict: true,
|
|
85
|
+
handler: (d) => {
|
|
86
|
+
fired.push(`moved${d.n ?? ''}`);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
accepted: {
|
|
90
|
+
predict: true,
|
|
91
|
+
handler: () => {
|
|
92
|
+
fired.push('accepted');
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
tooLate: {
|
|
96
|
+
predict: true,
|
|
97
|
+
handler: () => {
|
|
98
|
+
fired.push('tooLate');
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
// predict: false = サーバー確定後だけ実行する
|
|
102
|
+
charged: {
|
|
103
|
+
predict: false,
|
|
104
|
+
handler: () => {
|
|
105
|
+
fired.push('charged');
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
fanfare: {
|
|
109
|
+
predict: false,
|
|
110
|
+
handler: () => {
|
|
111
|
+
fired.push('fanfare');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
62
114
|
},
|
|
63
115
|
sendAction: ({ action, seq }) => sent.push({ action, seq }),
|
|
64
116
|
});
|
|
65
|
-
client.reset({ moves: 0, charged: 0 });
|
|
117
|
+
client.reset({ moves: 0, charged: 0, stampedAt: 0 });
|
|
66
118
|
return { client, sent, fired, states };
|
|
67
119
|
};
|
|
68
120
|
describe('createOptimisticActionClient', () => {
|
|
@@ -71,7 +123,7 @@ describe('createOptimisticActionClient', () => {
|
|
|
71
123
|
it('actions の handler は送信時に先行実行される', () => {
|
|
72
124
|
const { client, sent, states } = setup();
|
|
73
125
|
client.send('move');
|
|
74
|
-
expect(latest(states)).
|
|
126
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
75
127
|
expect(sent).toEqual([{ action: 'move', seq: 1 }]);
|
|
76
128
|
});
|
|
77
129
|
/**
|
|
@@ -81,7 +133,7 @@ describe('createOptimisticActionClient', () => {
|
|
|
81
133
|
it('serverActions にしか無い action は先行実行されない', () => {
|
|
82
134
|
const { client, sent, states } = setup();
|
|
83
135
|
client.send('notifyExternal');
|
|
84
|
-
expect(latest(states)).
|
|
136
|
+
expect(latest(states)).toMatchObject({ moves: 0, charged: 0 });
|
|
85
137
|
expect(sent).toEqual([{ action: 'notifyExternal', seq: 1 }]);
|
|
86
138
|
});
|
|
87
139
|
/**
|
|
@@ -91,46 +143,140 @@ describe('createOptimisticActionClient', () => {
|
|
|
91
143
|
it('同名で両方ある場合、先読みは actions 側だけ', () => {
|
|
92
144
|
const { client, states } = setup();
|
|
93
145
|
client.send('move');
|
|
94
|
-
expect(latest(states)).
|
|
146
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
95
147
|
});
|
|
96
148
|
});
|
|
97
|
-
describe('events
|
|
149
|
+
describe('events の配信', () => {
|
|
98
150
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
151
|
+
* 予測が当たった場合、確定時に再発火しない。二重に鳴ると効果音が 2 回鳴る。
|
|
152
|
+
* 先読みで 1 回だけ (predicted: true) 発火していること。
|
|
101
153
|
*/
|
|
102
|
-
it('
|
|
154
|
+
it('予測が当たったら確定時に再発火しない', () => {
|
|
103
155
|
const { client, fired } = setup();
|
|
104
|
-
client.send('move');
|
|
105
|
-
|
|
106
|
-
|
|
156
|
+
client.send('move'); // 先読みで moved が発火
|
|
157
|
+
expect(fired).toEqual(['moved']);
|
|
158
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'moved', data: {} }] });
|
|
159
|
+
expect(fired).toEqual(['moved']);
|
|
107
160
|
});
|
|
108
161
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
162
|
+
* IMPORTANT: 予測が外れた場合、サーバー側の正しい event が確定として発火する。
|
|
163
|
+
*
|
|
164
|
+
* 旧実装は「自分の ack なら actions 由来の events を全部 skip」していたため、
|
|
165
|
+
* 別の分岐を通っていると正しい event が永久に届かなかった。
|
|
111
166
|
*/
|
|
112
|
-
it('
|
|
167
|
+
it('予測が外れたらサーバー側の event が確定として発火する', () => {
|
|
113
168
|
const { client, fired } = setup();
|
|
114
|
-
client.send('
|
|
115
|
-
|
|
169
|
+
client.send('guess', { willEmit: 'accepted' }); // 先読みでは accepted
|
|
170
|
+
expect(fired).toEqual(['accepted']);
|
|
171
|
+
// サーバーは tooLate だった
|
|
172
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'tooLate', data: {} }] });
|
|
173
|
+
expect(fired).toEqual(['accepted', 'tooLate']);
|
|
174
|
+
});
|
|
175
|
+
/**
|
|
176
|
+
* serverActions 由来の events は先読みで走っていないので確定として発火する。
|
|
177
|
+
* サーバーは events を 1 本で送るが、クライアントが自分の発火記録を差し引くので
|
|
178
|
+
* 袋を分ける必要がない (旧 serverEvents は廃止)。
|
|
179
|
+
*/
|
|
180
|
+
it('先読みしていない event は 1 本のリストからでも発火する', () => {
|
|
181
|
+
const { client, fired } = setup();
|
|
182
|
+
client.send('move'); // 先読みで moved のみ
|
|
183
|
+
client.applyState({ moves: 1, charged: 5, stampedAt: 0 }, {
|
|
116
184
|
ack: 1,
|
|
117
185
|
from: 'me',
|
|
118
|
-
|
|
119
|
-
|
|
186
|
+
// actions 由来 (moved) と serverActions 由来 (charged) が同じ配列で届く
|
|
187
|
+
events: [
|
|
188
|
+
{ name: 'moved', data: {} },
|
|
189
|
+
{ name: 'charged', data: {} },
|
|
190
|
+
],
|
|
120
191
|
});
|
|
121
|
-
expect(fired).toEqual(['charged']);
|
|
192
|
+
expect(fired).toEqual(['moved', 'charged']);
|
|
122
193
|
});
|
|
123
|
-
/** 他プレイヤーの action
|
|
124
|
-
it('他プレイヤーの ack
|
|
194
|
+
/** 他プレイヤーの action なら先読みしていないので全部確定として発火する。 */
|
|
195
|
+
it('他プレイヤーの ack では全部確定として発火する', () => {
|
|
125
196
|
const { client, fired } = setup();
|
|
126
|
-
client.applyState({ moves: 1, charged: 5 }, {
|
|
197
|
+
client.applyState({ moves: 1, charged: 5, stampedAt: 0 }, {
|
|
127
198
|
ack: 1,
|
|
128
199
|
from: 'other',
|
|
129
|
-
events: [
|
|
130
|
-
|
|
200
|
+
events: [
|
|
201
|
+
{ name: 'moved', data: {} },
|
|
202
|
+
{ name: 'charged', data: {} },
|
|
203
|
+
],
|
|
131
204
|
});
|
|
132
205
|
expect(fired).toEqual(['moved', 'charged']);
|
|
133
206
|
});
|
|
207
|
+
/** predict: false の event は先読みで実行されず、確定時に 1 回だけ実行される。 */
|
|
208
|
+
it('predict: false の event は確定時にだけ実行される', () => {
|
|
209
|
+
const { client, fired } = setup();
|
|
210
|
+
client.send('guess', { willEmit: 'fanfare' });
|
|
211
|
+
expect(fired).toEqual([]);
|
|
212
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'fanfare', data: {} }] });
|
|
213
|
+
expect(fired).toEqual(['fanfare']);
|
|
214
|
+
});
|
|
215
|
+
/**
|
|
216
|
+
* IMPORTANT: 他プレイヤーの action が同じ出来事を起こしても二重実行しない。
|
|
217
|
+
*
|
|
218
|
+
* A と B が同時に同じ行送りを撃つと、サーバーは先着の A だけを通し、B の分は
|
|
219
|
+
* 握り潰す。 B から見て届くのは「A の action の ack」なので、記録を自分の pending に
|
|
220
|
+
* 紐づけていると引き当てられず、先読みで実行済みなのにもう一度実行してしまう。
|
|
221
|
+
* (e2e/prediction-misfire.mjs で実測した回帰)
|
|
222
|
+
*/
|
|
223
|
+
it('他プレイヤー由来の配信でも先読み済みなら二重実行しない', () => {
|
|
224
|
+
const { client, fired } = setup();
|
|
225
|
+
// 自分も撃った (先読みで moved1 を実行)
|
|
226
|
+
client.send('guess', { willEmit: 'moved', data: { n: 1 } });
|
|
227
|
+
expect(fired).toEqual(['moved1']);
|
|
228
|
+
// 先に着いた他プレイヤーの action が同じ出来事を起こして配信されてくる
|
|
229
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
|
|
230
|
+
expect(fired).toEqual(['moved1']);
|
|
231
|
+
});
|
|
232
|
+
/**
|
|
233
|
+
* IMPORTANT: 予測が外れた記録は「その action が ack された時点」で捨てる。
|
|
234
|
+
*
|
|
235
|
+
* 「未確定の action が全部無くなったら捨てる」だけだと、別の action が未確定な間
|
|
236
|
+
* ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回握り潰す。
|
|
237
|
+
* (レビュー指摘の再現)
|
|
238
|
+
*/
|
|
239
|
+
it('別の action が未確定でも、外れた記録はその ack で捨てる', () => {
|
|
240
|
+
const { client, fired } = setup();
|
|
241
|
+
client.send('guess', { willEmit: 'moved', data: { n: 1 } }); // seq 1: 先読みで実行
|
|
242
|
+
client.send('move'); // seq 2: まだ未確定のまま残す
|
|
243
|
+
expect(fired).toEqual(['moved1', 'moved']);
|
|
244
|
+
// seq 1 の ack。サーバーは moved1 を出さなかった = 予測が外れた
|
|
245
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [] });
|
|
246
|
+
// seq 2 が未確定でも、seq 1 の記録は捨てられている
|
|
247
|
+
// → 他プレイヤー由来の本物の moved1 は実行されるべき
|
|
248
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
|
|
249
|
+
expect(fired).toEqual(['moved1', 'moved', 'moved1']);
|
|
250
|
+
});
|
|
251
|
+
/**
|
|
252
|
+
* 予測が外れて実際には起きなかった出来事の記録は、その action の ack で破棄する。
|
|
253
|
+
* 残したままだと、次に本当にその出来事が起きたときに実行されなくなる。
|
|
254
|
+
*/
|
|
255
|
+
it('外れた記録は ack で破棄する', () => {
|
|
256
|
+
const { client, fired } = setup();
|
|
257
|
+
client.send('guess', { willEmit: 'moved', data: { n: 1 } });
|
|
258
|
+
expect(fired).toEqual(['moved1']);
|
|
259
|
+
// 自分の ack。サーバーは何も起こさなかった (予測が外れた)
|
|
260
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [] });
|
|
261
|
+
expect(fired).toEqual(['moved1']);
|
|
262
|
+
// 後から本当に起きた同じ出来事は、記録が消えているので実行される
|
|
263
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 100, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
|
|
264
|
+
expect(fired).toEqual(['moved1', 'moved1']);
|
|
265
|
+
});
|
|
266
|
+
/** 同じ event が 2 回来たら、先読み 1 回分だけを差し引く (多重集合の差)。 */
|
|
267
|
+
it('同じ event が複数回でも先読み分だけ差し引く', () => {
|
|
268
|
+
const { client, fired } = setup();
|
|
269
|
+
client.send('move'); // 先読みで moved 1 回
|
|
270
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, {
|
|
271
|
+
ack: 1,
|
|
272
|
+
from: 'me',
|
|
273
|
+
events: [
|
|
274
|
+
{ name: 'moved', data: {} },
|
|
275
|
+
{ name: 'moved', data: {} },
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
expect(fired).toEqual(['moved', 'moved']);
|
|
279
|
+
});
|
|
134
280
|
});
|
|
135
281
|
describe('pending キューと巻き戻し', () => {
|
|
136
282
|
/**
|
|
@@ -142,19 +288,19 @@ describe('createOptimisticActionClient', () => {
|
|
|
142
288
|
client.send('move'); // seq 1
|
|
143
289
|
client.send('move'); // seq 2
|
|
144
290
|
// seq 1 だけ確定。charged はサーバー側で加算済みの値が入っている
|
|
145
|
-
client.applyState({ moves: 1, charged: 7 }, { ack: 1, from: 'me' });
|
|
291
|
+
client.applyState({ moves: 1, charged: 7, stampedAt: 0 }, { ack: 1, from: 'me' });
|
|
146
292
|
// 確定 (moves:1) + 未 ack の seq 2 を再適用 = moves:2、charged はサーバー値のまま
|
|
147
|
-
expect(latest(states)).
|
|
293
|
+
expect(latest(states)).toMatchObject({ moves: 2, charged: 7 });
|
|
148
294
|
});
|
|
149
295
|
/** __action_error で該当 seq を除去し、残りを再適用する。 */
|
|
150
296
|
it('rollback は該当 action だけを取り消す', () => {
|
|
151
297
|
const { client, states } = setup();
|
|
152
298
|
client.send('move'); // seq 1
|
|
153
299
|
client.send('move'); // seq 2
|
|
154
|
-
expect(latest(states)).
|
|
300
|
+
expect(latest(states)).toMatchObject({ moves: 2, charged: 0 });
|
|
155
301
|
client.rollback(1);
|
|
156
302
|
// seq 1 が消えて seq 2 だけ再適用される
|
|
157
|
-
expect(latest(states)).
|
|
303
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
158
304
|
});
|
|
159
305
|
/** 先行実行で throw した action は pending に積まれず、送信だけ行われる。 */
|
|
160
306
|
it('先行実行が throw した action は pending に積まれない', () => {
|
|
@@ -162,23 +308,25 @@ describe('createOptimisticActionClient', () => {
|
|
|
162
308
|
client.send('boom');
|
|
163
309
|
expect(sent).toEqual([{ action: 'boom', seq: 1 }]);
|
|
164
310
|
// pending が空なので、確定 state がそのまま表示される
|
|
165
|
-
client.applyState({ moves: 9, charged: 9 }, {});
|
|
166
|
-
expect(latest(states)).
|
|
311
|
+
client.applyState({ moves: 9, charged: 9, stampedAt: 0 }, {});
|
|
312
|
+
expect(latest(states)).toMatchObject({ moves: 9, charged: 9 });
|
|
167
313
|
});
|
|
168
314
|
});
|
|
169
315
|
describe('applyDelta', () => {
|
|
170
|
-
/** delta 経路でも
|
|
171
|
-
it('
|
|
316
|
+
/** delta 経路でも events の差し引きは applyState と揃っている。 */
|
|
317
|
+
it('先読み済みを差し引いた残りだけを発火する', () => {
|
|
172
318
|
const { client, fired } = setup();
|
|
173
|
-
client.send('move');
|
|
319
|
+
client.send('move'); // 先読みで moved
|
|
174
320
|
const ok = client.applyDelta([{ op: 'replace', path: '/charged', value: 3 }], {
|
|
175
321
|
ack: 1,
|
|
176
322
|
from: 'me',
|
|
177
|
-
events: [
|
|
178
|
-
|
|
323
|
+
events: [
|
|
324
|
+
{ name: 'moved', data: {} },
|
|
325
|
+
{ name: 'charged', data: {} },
|
|
326
|
+
],
|
|
179
327
|
});
|
|
180
328
|
expect(ok).toBe(true);
|
|
181
|
-
expect(fired).toEqual(['charged']);
|
|
329
|
+
expect(fired).toEqual(['moved', 'charged']);
|
|
182
330
|
});
|
|
183
331
|
/** patch が当たらないときは events を発火せず false を返す (transport がフル state を再要求する)。 */
|
|
184
332
|
it('patch 適用に失敗したら events を発火せず false を返す', () => {
|
|
@@ -186,7 +334,6 @@ describe('createOptimisticActionClient', () => {
|
|
|
186
334
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
187
335
|
const ok = client.applyDelta([{ op: 'replace', path: '/missing/deep', value: 1 }], {
|
|
188
336
|
events: [{ name: 'moved', data: {} }],
|
|
189
|
-
serverEvents: [{ name: 'charged', data: {} }],
|
|
190
337
|
});
|
|
191
338
|
expect(ok).toBe(false);
|
|
192
339
|
expect(fired).toEqual([]);
|
|
@@ -202,7 +349,7 @@ describe('createOptimisticActionClient', () => {
|
|
|
202
349
|
const { client, sent, states } = setup();
|
|
203
350
|
client.send('legacy');
|
|
204
351
|
// state は動かず、送信だけ行われる
|
|
205
|
-
expect(latest(states)).
|
|
352
|
+
expect(latest(states)).toMatchObject({ moves: 0, charged: 0 });
|
|
206
353
|
expect(sent).toEqual([{ action: 'legacy', seq: 1 }]);
|
|
207
354
|
});
|
|
208
355
|
/** 再適用でも同じ。pending に積まれていても brand 付きなら実行しない。 */
|
|
@@ -210,9 +357,9 @@ describe('createOptimisticActionClient', () => {
|
|
|
210
357
|
const { client, states } = setup();
|
|
211
358
|
client.send('move'); // seq 1 (pending へ)
|
|
212
359
|
client.send('legacy'); // seq 2 (pending へは積まれない)
|
|
213
|
-
client.applyState({ moves: 0, charged: 0 }, {});
|
|
360
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, {});
|
|
214
361
|
// 確定 state に move だけが再適用され、legacy の +1000 は入らない
|
|
215
|
-
expect(latest(states)).
|
|
362
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
216
363
|
});
|
|
217
364
|
/**
|
|
218
365
|
* 再適用中に handler が途中まで state を変更してから throw した場合、その部分変更を
|
|
@@ -225,8 +372,84 @@ describe('createOptimisticActionClient', () => {
|
|
|
225
372
|
// 送信時点で partial は throw するので pending に入らず、moves は 1 のまま
|
|
226
373
|
expect(latest(states).moves).toBe(1);
|
|
227
374
|
// 確定 state を受けて再適用しても、partial の部分変更は混ざらない
|
|
228
|
-
client.applyState({ moves: 0, charged: 0 }, {});
|
|
229
|
-
expect(latest(states)).
|
|
375
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, {});
|
|
376
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
/**
|
|
380
|
+
* ctx.now の配布。
|
|
381
|
+
*
|
|
382
|
+
* actions はクライアント先読みとサーバーで 2 回走るため、handler の中で Date.now() を
|
|
383
|
+
* 読むと端末の時計ズレがそのまま state に入る。ctx.now はサーバーとのクロック
|
|
384
|
+
* オフセットで補正した推定値を配ることでこれを防ぐ。
|
|
385
|
+
*/
|
|
386
|
+
describe('ctx.now', () => {
|
|
387
|
+
/** オフセット未取得なら素のローカル時刻。オフラインでも壊れないこと。 */
|
|
388
|
+
it('サーバー時刻を観測する前はローカル時刻が入る', () => {
|
|
389
|
+
const { client, states } = setup();
|
|
390
|
+
const before = Date.now();
|
|
391
|
+
client.send('move');
|
|
392
|
+
expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
|
|
393
|
+
expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
|
|
394
|
+
});
|
|
395
|
+
/**
|
|
396
|
+
* 端末の時計が大きくズレていても、サーバー時刻の観測でオフセットが補正される。
|
|
397
|
+
* ここでは「サーバーが 1 時間先」を観測させ、ctx.now がそちらへ寄ることを見る。
|
|
398
|
+
*/
|
|
399
|
+
it('サーバー時刻を観測するとオフセット分ずれた値が入る', () => {
|
|
400
|
+
const { client, states } = setup();
|
|
401
|
+
const ONE_HOUR = 3600 * 1000;
|
|
402
|
+
client.observeServerTime(Date.now() + ONE_HOUR);
|
|
403
|
+
client.send('move');
|
|
404
|
+
const stamped = latest(states).stampedAt;
|
|
405
|
+
// 1 時間先へ寄っている (テスト実行のブレを考慮して幅を持たせる)
|
|
406
|
+
expect(stamped).toBeGreaterThan(Date.now() + ONE_HOUR - 5000);
|
|
407
|
+
expect(stamped).toBeLessThan(Date.now() + ONE_HOUR + 5000);
|
|
408
|
+
});
|
|
409
|
+
/** 数値でない / 欠けている場合は無視する (serverTime を持たないメッセージ用)。 */
|
|
410
|
+
it('不正な観測値は無視される', () => {
|
|
411
|
+
const { client, states } = setup();
|
|
412
|
+
client.observeServerTime(undefined);
|
|
413
|
+
client.observeServerTime(Number.NaN);
|
|
414
|
+
const before = Date.now();
|
|
415
|
+
client.send('move');
|
|
416
|
+
expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
|
|
417
|
+
expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
|
|
418
|
+
});
|
|
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
|
+
/**
|
|
437
|
+
* IMPORTANT: 再適用 (reconciliation でのやり直し) では初回予測時の now を使い回す。
|
|
438
|
+
*
|
|
439
|
+
* 取り直すと、サーバーから ack が来るたびに handler が別の時刻で走り、
|
|
440
|
+
* timerEndsAt のような値が毎回ズレて秒読みがガタつく。
|
|
441
|
+
*/
|
|
442
|
+
it('再適用でも初回予測時の now を使い回す', () => {
|
|
443
|
+
const { client, states } = setup();
|
|
444
|
+
client.send('move');
|
|
445
|
+
const firstStamp = latest(states).stampedAt;
|
|
446
|
+
// 再適用の直前にオフセットを大きく動かす。now を取り直す実装ならここで
|
|
447
|
+
// stampedAt が 1 時間ジャンプするので、使い回しているかを確実に判別できる。
|
|
448
|
+
client.observeServerTime(Date.now() + 3600 * 1000);
|
|
449
|
+
// 別プレイヤーの ack を受けて再適用させる (自分の pending は残る)
|
|
450
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other' });
|
|
451
|
+
expect(latest(states).moves).toBe(1);
|
|
452
|
+
expect(latest(states).stampedAt).toBe(firstStamp);
|
|
230
453
|
});
|
|
231
454
|
});
|
|
232
455
|
});
|
|
@@ -74,6 +74,9 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
|
|
|
74
74
|
}
|
|
75
75
|
const msgType = parsed.type;
|
|
76
76
|
console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
|
|
77
|
+
// サーバーは全ブロードキャストに実時刻を載せる。どのメッセージでもオフセットを
|
|
78
|
+
// 更新できるので、tick が回っている限り追加の往復は要らない。
|
|
79
|
+
client.observeServerTime(parsed.serverTime);
|
|
77
80
|
if (msgType === '__room_init') {
|
|
78
81
|
// サーバー (relay-room / sync-room / game-room) はいずれも接続 URL の playerId
|
|
79
82
|
// クエリをそのまま `myId` として echo する。つまり parsed.myId は常に引数 playerId と
|
|
@@ -123,10 +126,9 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
|
|
|
123
126
|
const ack = parsed.ack;
|
|
124
127
|
const from = parsed.from;
|
|
125
128
|
const evts = parsed.events ?? [];
|
|
126
|
-
const serverEvts = parsed.serverEvents ?? [];
|
|
127
129
|
serverSeq = parsed.seq ?? serverSeq + 1;
|
|
128
130
|
requestStatePending = false;
|
|
129
|
-
client.applyState(parsed.state, { ack, from, events: evts
|
|
131
|
+
client.applyState(parsed.state, { ack, from, events: evts });
|
|
130
132
|
return;
|
|
131
133
|
}
|
|
132
134
|
// ─── Action 結果 (差分パッチ) ────────────────────────
|
|
@@ -134,7 +136,6 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
|
|
|
134
136
|
const ack = parsed.ack;
|
|
135
137
|
const from = parsed.from;
|
|
136
138
|
const evts = parsed.events ?? [];
|
|
137
|
-
const serverEvts = parsed.serverEvents ?? [];
|
|
138
139
|
const newSeq = parsed.seq ?? serverSeq + 1;
|
|
139
140
|
if (newSeq !== serverSeq + 1) {
|
|
140
141
|
requestFullState();
|
|
@@ -144,7 +145,6 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
|
|
|
144
145
|
ack,
|
|
145
146
|
from,
|
|
146
147
|
events: evts,
|
|
147
|
-
serverEvents: serverEvts,
|
|
148
148
|
});
|
|
149
149
|
if (!ok) {
|
|
150
150
|
requestFullState();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
4
|
+
*
|
|
5
|
+
* サーバー時刻の推定値をクライアント全体へ配る。
|
|
6
|
+
*
|
|
7
|
+
* state に入っている `endsAt` のような絶対時刻はサーバーの時計で打たれている。
|
|
8
|
+
* それを端末の `Date.now()` と引き算すると、端末の時計ズレがそのまま表示のズレになる
|
|
9
|
+
* (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
|
|
10
|
+
* 揃えるための関数。
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const remain = Math.ceil((state.game.timerEndsAt - serverNow()) / 1000);
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
|
|
17
|
+
* 未接続 / 未観測ならローカル時刻をそのまま返す (オフラインでも壊れない)。
|
|
18
|
+
*/
|
|
19
|
+
/** transport から呼ぶ内部関数。サーバーが打刻した時刻を観測してオフセットを更新する。 */
|
|
20
|
+
export declare function observeServerTime(serverTime: number | undefined): void;
|
|
21
|
+
/**
|
|
22
|
+
* サーバー時刻の推定値 (ms)。
|
|
23
|
+
*
|
|
24
|
+
* カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
|
|
25
|
+
* state の到着とは無関係にいつでも呼べる。
|
|
26
|
+
*/
|
|
27
|
+
export declare function serverNow(): number;
|
|
28
|
+
/** ソロモードなど「自分自身がサーバー」の経路で使う。 */
|
|
29
|
+
export declare function resetServerTimeOffset(): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
4
|
+
*
|
|
5
|
+
* サーバー時刻の推定値をクライアント全体へ配る。
|
|
6
|
+
*
|
|
7
|
+
* state に入っている `endsAt` のような絶対時刻はサーバーの時計で打たれている。
|
|
8
|
+
* それを端末の `Date.now()` と引き算すると、端末の時計ズレがそのまま表示のズレになる
|
|
9
|
+
* (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
|
|
10
|
+
* 揃えるための関数。
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const remain = Math.ceil((state.game.timerEndsAt - serverNow()) / 1000);
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
|
|
17
|
+
* 未接続 / 未観測ならローカル時刻をそのまま返す (オフラインでも壊れない)。
|
|
18
|
+
*/
|
|
19
|
+
let offset = 0;
|
|
20
|
+
/** transport から呼ぶ内部関数。サーバーが打刻した時刻を観測してオフセットを更新する。 */
|
|
21
|
+
export function observeServerTime(serverTime) {
|
|
22
|
+
if (typeof serverTime !== 'number' || !Number.isFinite(serverTime))
|
|
23
|
+
return;
|
|
24
|
+
// 下り片道遅延を無視するので推定は実サーバー時刻より僅かに遅れる。
|
|
25
|
+
// 表示用途では無視できる誤差なので、精度より単純さを取る。
|
|
26
|
+
offset = serverTime - Date.now();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* サーバー時刻の推定値 (ms)。
|
|
30
|
+
*
|
|
31
|
+
* カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
|
|
32
|
+
* state の到着とは無関係にいつでも呼べる。
|
|
33
|
+
*/
|
|
34
|
+
export function serverNow() {
|
|
35
|
+
return Date.now() + offset;
|
|
36
|
+
}
|
|
37
|
+
/** ソロモードなど「自分自身がサーバー」の経路で使う。 */
|
|
38
|
+
export function resetServerTimeOffset() {
|
|
39
|
+
offset = 0;
|
|
40
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -56,6 +56,24 @@ export interface PlayersChangedMessage {
|
|
|
56
56
|
players: Record<string, PlayerVoiceState>;
|
|
57
57
|
}
|
|
58
58
|
export type Emit = (eventName: string, data?: Record<string, unknown>) => void;
|
|
59
|
+
/**
|
|
60
|
+
* events handler。
|
|
61
|
+
*
|
|
62
|
+
* `emit(name, data)` の `data` をそのまま受け取る。先読み中か確定後かは渡さない
|
|
63
|
+
* (どちらで呼ばれても同じ処理をする前提。 `predict` で宣言済みなので分岐の必要がない)。
|
|
64
|
+
*/
|
|
65
|
+
export type EventHandler = (data: Record<string, unknown>) => void;
|
|
66
|
+
/**
|
|
67
|
+
* events の購読宣言。
|
|
68
|
+
*
|
|
69
|
+
* 先読みは外れることがあり、実行してしまったものは取り消せない。取り消せない副作用
|
|
70
|
+
* (analytics / 実績解除 / 長い演出) は必ず `predict: false` にすること。
|
|
71
|
+
*/
|
|
72
|
+
export interface EventSubscription {
|
|
73
|
+
/** 先読み時点で実行してよいか。宣言必須。 */
|
|
74
|
+
predict: boolean;
|
|
75
|
+
handler: EventHandler;
|
|
76
|
+
}
|
|
59
77
|
/** dev harness で観測される 1 件の emit。`data` は `emit(name)` 省略時に空 object になる。 */
|
|
60
78
|
export interface ServerEvent {
|
|
61
79
|
name: string;
|
|
@@ -71,20 +89,93 @@ export interface SeededRandom {
|
|
|
71
89
|
* 素の action handler の実行文脈。空なのは意図的。
|
|
72
90
|
*
|
|
73
91
|
* 素の handler はサーバーとクライアント先読みの両方で走る。クライアントが自力で
|
|
74
|
-
* 再現できない値 (tick /
|
|
75
|
-
*
|
|
92
|
+
* 再現できない値 (tick / 乱数) をここで配ると、サーバー・ソロ・dev では本物が入るのに
|
|
93
|
+
* オンラインの先読みだけ値がズレる、という一番気付きにくい形で壊れる。
|
|
76
94
|
* そういう値が要る処理は `serverActions` 側に書く。
|
|
77
95
|
*/
|
|
78
|
-
export
|
|
96
|
+
export interface ActionContext {
|
|
97
|
+
/**
|
|
98
|
+
* サーバーで処理される時刻 (ms)。先読みではクロックオフセットからの推定値。
|
|
99
|
+
*
|
|
100
|
+
* 推定なのでサーバーとは数十 ms ずれる。時刻での分岐に使うと境界で判定が割れるので、
|
|
101
|
+
* 分岐は `update()` (サーバー専用) で行う。
|
|
102
|
+
*
|
|
103
|
+
* 同じ action を再適用しても値は変わらない (初回予測時の値を使い回す)。
|
|
104
|
+
*/
|
|
105
|
+
now: number;
|
|
106
|
+
emit: Emit;
|
|
107
|
+
/** 予約はサーバーだけが持つ。先読みでは戻り値を計算するだけで、予約も取り消しもしない。 */
|
|
108
|
+
schedule(options: ScheduleOptions): number;
|
|
109
|
+
unschedule(key: string): void;
|
|
110
|
+
}
|
|
111
|
+
/** 予約されたタイマーの識別子付き宣言。 */
|
|
112
|
+
export interface ScheduleOptions {
|
|
113
|
+
/** 予約の識別子。同じ key で予約し直すと置き換わる (古い予約が残ると二重に発火する)。 */
|
|
114
|
+
key: string;
|
|
115
|
+
/** 絶対時刻 (ms)。`after` と排他。過去なら即座に発火する。 */
|
|
116
|
+
at?: number;
|
|
117
|
+
/** 何秒後か。`at` と排他。 */
|
|
118
|
+
after?: number;
|
|
119
|
+
action: string;
|
|
120
|
+
payload?: Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* サーバー権威のタイマー。
|
|
124
|
+
*
|
|
125
|
+
* クライアントの時計やアプリのバックグラウンド化に依存せず、サーバーが指定時刻に
|
|
126
|
+
* 自分で起きて action を撃つ。`tickRate` で毎秒ポーリングする必要が無くなり、
|
|
127
|
+
* その間 Durable Object は hibernate できる。
|
|
128
|
+
*/
|
|
129
|
+
export interface Scheduler {
|
|
130
|
+
/**
|
|
131
|
+
* 予約して確定した絶対時刻 (ms) を返す。表示用の endsAt にそのまま使える。
|
|
132
|
+
*
|
|
133
|
+
* 発火は at-least-once。同じ予約が 2 回実行されうるので、handler 側で「自分が予約した
|
|
134
|
+
* ものか」を state と突き合わせて弾くこと (action 名や key では区別できない)。
|
|
135
|
+
*/
|
|
136
|
+
schedule(options: ScheduleOptions): number;
|
|
137
|
+
unschedule(key: string): void;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* 予約から発火した action に渡る playerId。
|
|
141
|
+
*
|
|
142
|
+
* 送信者がいないので、人間の操作と区別するための定数。
|
|
143
|
+
* `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
|
|
144
|
+
*/
|
|
145
|
+
export declare const SCHEDULED_ACTOR = "__scheduled";
|
|
79
146
|
/** `serverActions` handler の実行文脈。サーバーでしか走らないので tick と乱数を渡せる。 */
|
|
80
147
|
export interface ServerActionContext {
|
|
81
148
|
tick: number;
|
|
82
149
|
random: SeededRandom;
|
|
150
|
+
/** サーバーの実時刻 (ms)。同じ dispatch の `actions` に渡る `ctx.now` と同一値。 */
|
|
151
|
+
now: number;
|
|
152
|
+
emit: Emit;
|
|
153
|
+
schedule(options: ScheduleOptions): number;
|
|
154
|
+
unschedule(key: string): void;
|
|
83
155
|
}
|
|
84
156
|
/** @deprecated `ServerActionContext` を使う。 */
|
|
85
157
|
export type ServerOnlyActionContext = ServerActionContext;
|
|
86
|
-
|
|
87
|
-
|
|
158
|
+
/**
|
|
159
|
+
* action handler の引数。
|
|
160
|
+
*
|
|
161
|
+
* オブジェクトなのは使うものだけ書けるようにするため。`ctx` を入れ子で残しているのは、
|
|
162
|
+
* 実行環境が与えるものをひとまとまりで helper へ渡せるようにするため。
|
|
163
|
+
*/
|
|
164
|
+
export interface ActionArgs<S> {
|
|
165
|
+
state: S;
|
|
166
|
+
payload: any;
|
|
167
|
+
playerId: string;
|
|
168
|
+
ctx: ActionContext;
|
|
169
|
+
}
|
|
170
|
+
export type ActionHandler<S> = (args: ActionArgs<S>) => void;
|
|
171
|
+
/** `serverActions` handler の引数。`ctx` にサーバー限定の tick / random が入る。 */
|
|
172
|
+
export interface ServerActionArgs<S> {
|
|
173
|
+
state: S;
|
|
174
|
+
payload: any;
|
|
175
|
+
playerId: string;
|
|
176
|
+
ctx: ServerActionContext;
|
|
177
|
+
}
|
|
178
|
+
export type ServerActionHandler<S> = (args: ServerActionArgs<S>) => Promise<void> | void;
|
|
88
179
|
/** @deprecated `ServerActionHandler` を使う。 */
|
|
89
180
|
export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
|
|
90
181
|
/**
|
|
@@ -94,6 +185,21 @@ export type ServerOnlyActionHandlerFn<S> = ServerActionHandler<S>;
|
|
|
94
185
|
export type ServerOnlyAction<S> = ServerActionHandler<S> & {
|
|
95
186
|
readonly __serverOnly: true;
|
|
96
187
|
};
|
|
188
|
+
/** `update()` の ctx。サーバーでしか走らないので tick / random / playerInputs を持つ。 */
|
|
189
|
+
export interface UpdateContext {
|
|
190
|
+
random: SeededRandom;
|
|
191
|
+
tick: number;
|
|
192
|
+
/** サーバーの実時刻 (ms)。update はサーバーでしか走らないので常に正確。 */
|
|
193
|
+
now: number;
|
|
194
|
+
schedule(options: ScheduleOptions): number;
|
|
195
|
+
unschedule(key: string): void;
|
|
196
|
+
emit: Emit;
|
|
197
|
+
playerInputs: Record<string, Record<string, any>>;
|
|
198
|
+
}
|
|
199
|
+
export interface UpdateArgs<S> {
|
|
200
|
+
state: S;
|
|
201
|
+
ctx: UpdateContext;
|
|
202
|
+
}
|
|
97
203
|
export interface GameLogic<S> {
|
|
98
204
|
/**
|
|
99
205
|
* seats には kind !== 'player' の席 (spectator / admin) も含まれる。
|
|
@@ -116,19 +222,22 @@ export interface GameLogic<S> {
|
|
|
116
222
|
* (旧 `serverOnly()` 相当) になる。
|
|
117
223
|
*/
|
|
118
224
|
serverActions?: Record<string, ServerActionHandler<S>>;
|
|
119
|
-
update(
|
|
120
|
-
random: SeededRandom;
|
|
121
|
-
tick: number;
|
|
122
|
-
emit: Emit;
|
|
123
|
-
playerInputs: Record<string, Record<string, any>>;
|
|
124
|
-
}): void;
|
|
225
|
+
update(args: UpdateArgs<S>): void;
|
|
125
226
|
tickRate?: number;
|
|
126
227
|
}
|
|
127
228
|
export interface GameConfig<S> extends ConnectionCallbacks {
|
|
128
229
|
logic: GameLogic<S>;
|
|
129
230
|
onState: (state: S, myPlayerId: string) => void;
|
|
130
231
|
inputs: (sendAction: (type: string, payload?: any) => void) => void;
|
|
131
|
-
|
|
232
|
+
/**
|
|
233
|
+
* `emit(name, data)` の購読。 キーごとに `predict` の宣言が必須。
|
|
234
|
+
*
|
|
235
|
+
* 同じ出来事が二重に実行されないよう、SDK は「先読みで実行した記録」を持ち、
|
|
236
|
+
* サーバーから同一の event (name と data が完全一致) が届いたら実行を抑止する。
|
|
237
|
+
* したがって `data` には**クライアントとサーバーで必ず同じ値になるもの**だけを
|
|
238
|
+
* 入れること。`ctx.now` や乱数 ID を混ぜると一致せず二重実行になる。
|
|
239
|
+
*/
|
|
240
|
+
events?: Record<string, EventSubscription>;
|
|
132
241
|
playerCount: number;
|
|
133
242
|
/** Dev harness のデフォルト向き。manifest.json の `orientation` を渡す。 */
|
|
134
243
|
orientation?: 'portrait' | 'landscape';
|
package/dist/types.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 予約から発火した action に渡る playerId。
|
|
3
|
+
*
|
|
4
|
+
* 送信者がいないので、人間の操作と区別するための定数。
|
|
5
|
+
* `if (playerId !== SCHEDULED_ACTOR) return;` で人間からの直接実行を弾ける。
|
|
6
|
+
*/
|
|
7
|
+
export const SCHEDULED_ACTOR = '__scheduled';
|
|
1
8
|
/** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
|
|
2
9
|
export const SERVER_TIME = '__SERVER_TIME__';
|
|
3
10
|
/** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
|