@uzuhq/code-sdk 0.4.0 → 0.6.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.d.ts +3 -2
- package/dist/run/local-server-action.js +83 -30
- package/dist/run/local-server-action.test.d.ts +1 -0
- package/dist/run/local-server-action.test.js +218 -0
- package/dist/run/optimistic-action-client.d.ts +17 -6
- package/dist/run/optimistic-action-client.js +108 -20
- package/dist/run/optimistic-action-client.test.d.ts +1 -0
- package/dist/run/optimistic-action-client.test.js +452 -0
- package/dist/run/server-action.js +3 -0
- package/dist/server-clock.d.ts +29 -0
- package/dist/server-clock.js +40 -0
- package/dist/server-only.d.ts +22 -15
- package/dist/server-only.js +10 -9
- package/dist/types.d.ts +162 -11
- package/dist/types.js +7 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* optimistic-action-client.ts の unit test。
|
|
3
|
+
*
|
|
4
|
+
* `logic.actions` (クライアント先読み + サーバーの 2 回実行) と `logic.serverActions`
|
|
5
|
+
* (サーバーのみ 1 回実行) を分離した設計が、events の配信と pending キューの巻き戻しの
|
|
6
|
+
* 両方で正しく閉じることを検証する。
|
|
7
|
+
*
|
|
8
|
+
* events はサーバーから 1 本のリストで届き、クライアントが「先読みで自分が配信した分」を
|
|
9
|
+
* 差し引いて残りを確定として発火する。予測が当たれば二重に鳴らず、外れればサーバー側の
|
|
10
|
+
* 正しい event が追って届く。
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
import { createOptimisticActionClient } from './optimistic-action-client.js';
|
|
14
|
+
import { serverOnly } from '../server-only.js';
|
|
15
|
+
import { resetServerTimeOffset } from '../server-clock.js';
|
|
16
|
+
/** 旧 serverOnly() ブランド付き handler。actions に残っている古い logic を再現する。 */
|
|
17
|
+
const legacyServerOnly = serverOnly((state) => {
|
|
18
|
+
state.charged += 1000;
|
|
19
|
+
});
|
|
20
|
+
const makeLogic = () => ({
|
|
21
|
+
setup: () => ({ moves: 0, charged: 0, stampedAt: 0 }),
|
|
22
|
+
actions: {
|
|
23
|
+
move: (state, _payload, _playerId, emit, ctx) => {
|
|
24
|
+
state.moves += 1;
|
|
25
|
+
state.stampedAt = ctx.now;
|
|
26
|
+
emit('moved', {});
|
|
27
|
+
},
|
|
28
|
+
// 先読みでも ctx.schedule を呼ぶ action。型にはあるので呼べてしまう。
|
|
29
|
+
startTimer: (state, _payload, _playerId, _emit, ctx) => {
|
|
30
|
+
state.stampedAt = ctx.schedule({
|
|
31
|
+
key: 'timer',
|
|
32
|
+
after: 60,
|
|
33
|
+
action: 'timer.fire',
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
// 予測の結果によって別の event を出す (予測ミスの再現用)
|
|
37
|
+
guess: (state, payload, _playerId, emit) => {
|
|
38
|
+
state.moves += 1;
|
|
39
|
+
emit(payload?.willEmit ?? 'accepted', payload?.data ?? {});
|
|
40
|
+
},
|
|
41
|
+
boom: () => {
|
|
42
|
+
throw new Error('always fails');
|
|
43
|
+
},
|
|
44
|
+
// 途中まで state を書き換えてから throw する (部分ミューテーションの検証用)
|
|
45
|
+
partial: (state) => {
|
|
46
|
+
state.moves += 1;
|
|
47
|
+
throw new Error('fails after mutating');
|
|
48
|
+
},
|
|
49
|
+
// 旧 serverOnly() が actions に残っている logic の再現
|
|
50
|
+
// @ts-expect-error 移行期の互換挙動を検証するため意図的に型違反させる
|
|
51
|
+
legacy: legacyServerOnly,
|
|
52
|
+
},
|
|
53
|
+
serverActions: {
|
|
54
|
+
// move と同名 = 同じ action の「サーバーだけで走る続き」
|
|
55
|
+
move: (state, _payload, _playerId, emit, ctx) => {
|
|
56
|
+
state.charged += ctx.tick;
|
|
57
|
+
emit('charged', {});
|
|
58
|
+
},
|
|
59
|
+
// actions に無い名前 = 先読みされない action (旧 serverOnly 相当)
|
|
60
|
+
notifyExternal: (state) => {
|
|
61
|
+
state.charged += 100;
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
update: () => { },
|
|
65
|
+
});
|
|
66
|
+
/** 直近の onState 通知。tsconfig の lib が Array#at 未対応なので添字で取る。 */
|
|
67
|
+
const latest = (states) => states[states.length - 1];
|
|
68
|
+
const setup = () => {
|
|
69
|
+
// サーバー時刻オフセットは module 単位で共有されるので、テスト間で持ち越さない。
|
|
70
|
+
resetServerTimeOffset();
|
|
71
|
+
const sent = [];
|
|
72
|
+
const fired = [];
|
|
73
|
+
const states = [];
|
|
74
|
+
const client = createOptimisticActionClient({
|
|
75
|
+
logic: makeLogic(),
|
|
76
|
+
playerId: 'me',
|
|
77
|
+
onState: (s) => states.push(structuredClone(s)),
|
|
78
|
+
events: {
|
|
79
|
+
// predict: true = 先読み時点で実行する
|
|
80
|
+
moved: {
|
|
81
|
+
predict: true,
|
|
82
|
+
handler: (d) => {
|
|
83
|
+
fired.push(`moved${d.n ?? ''}`);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
accepted: {
|
|
87
|
+
predict: true,
|
|
88
|
+
handler: () => {
|
|
89
|
+
fired.push('accepted');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
tooLate: {
|
|
93
|
+
predict: true,
|
|
94
|
+
handler: () => {
|
|
95
|
+
fired.push('tooLate');
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
// predict: false = サーバー確定後だけ実行する
|
|
99
|
+
charged: {
|
|
100
|
+
predict: false,
|
|
101
|
+
handler: () => {
|
|
102
|
+
fired.push('charged');
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
fanfare: {
|
|
106
|
+
predict: false,
|
|
107
|
+
handler: () => {
|
|
108
|
+
fired.push('fanfare');
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
sendAction: ({ action, seq }) => sent.push({ action, seq }),
|
|
113
|
+
});
|
|
114
|
+
client.reset({ moves: 0, charged: 0, stampedAt: 0 });
|
|
115
|
+
return { client, sent, fired, states };
|
|
116
|
+
};
|
|
117
|
+
describe('createOptimisticActionClient', () => {
|
|
118
|
+
describe('先読みの対象', () => {
|
|
119
|
+
/** actions にある handler だけがクライアントで先行実行される。 */
|
|
120
|
+
it('actions の handler は送信時に先行実行される', () => {
|
|
121
|
+
const { client, sent, states } = setup();
|
|
122
|
+
client.send('move');
|
|
123
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
124
|
+
expect(sent).toEqual([{ action: 'move', seq: 1 }]);
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* serverActions にしか無い action は先読みされない。state を触らずサーバーへ送るだけ。
|
|
128
|
+
* 旧 serverOnly() と同じ挙動。
|
|
129
|
+
*/
|
|
130
|
+
it('serverActions にしか無い action は先行実行されない', () => {
|
|
131
|
+
const { client, sent, states } = setup();
|
|
132
|
+
client.send('notifyExternal');
|
|
133
|
+
expect(latest(states)).toMatchObject({ moves: 0, charged: 0 });
|
|
134
|
+
expect(sent).toEqual([{ action: 'notifyExternal', seq: 1 }]);
|
|
135
|
+
});
|
|
136
|
+
/**
|
|
137
|
+
* 同名で両方定義されている場合、先読みされるのは actions 側だけ。
|
|
138
|
+
* serverActions 側の結果 (charged) はサーバーの ack が来るまで反映されない。
|
|
139
|
+
*/
|
|
140
|
+
it('同名で両方ある場合、先読みは actions 側だけ', () => {
|
|
141
|
+
const { client, states } = setup();
|
|
142
|
+
client.send('move');
|
|
143
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
describe('events の配信', () => {
|
|
147
|
+
/**
|
|
148
|
+
* 予測が当たった場合、確定時に再発火しない。二重に鳴ると効果音が 2 回鳴る。
|
|
149
|
+
* 先読みで 1 回だけ (predicted: true) 発火していること。
|
|
150
|
+
*/
|
|
151
|
+
it('予測が当たったら確定時に再発火しない', () => {
|
|
152
|
+
const { client, fired } = setup();
|
|
153
|
+
client.send('move'); // 先読みで moved が発火
|
|
154
|
+
expect(fired).toEqual(['moved']);
|
|
155
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'moved', data: {} }] });
|
|
156
|
+
expect(fired).toEqual(['moved']);
|
|
157
|
+
});
|
|
158
|
+
/**
|
|
159
|
+
* IMPORTANT: 予測が外れた場合、サーバー側の正しい event が確定として発火する。
|
|
160
|
+
*
|
|
161
|
+
* 旧実装は「自分の ack なら actions 由来の events を全部 skip」していたため、
|
|
162
|
+
* 別の分岐を通っていると正しい event が永久に届かなかった。
|
|
163
|
+
*/
|
|
164
|
+
it('予測が外れたらサーバー側の event が確定として発火する', () => {
|
|
165
|
+
const { client, fired } = setup();
|
|
166
|
+
client.send('guess', { willEmit: 'accepted' }); // 先読みでは accepted
|
|
167
|
+
expect(fired).toEqual(['accepted']);
|
|
168
|
+
// サーバーは tooLate だった
|
|
169
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'tooLate', data: {} }] });
|
|
170
|
+
expect(fired).toEqual(['accepted', 'tooLate']);
|
|
171
|
+
});
|
|
172
|
+
/**
|
|
173
|
+
* serverActions 由来の events は先読みで走っていないので確定として発火する。
|
|
174
|
+
* サーバーは events を 1 本で送るが、クライアントが自分の発火記録を差し引くので
|
|
175
|
+
* 袋を分ける必要がない (旧 serverEvents は廃止)。
|
|
176
|
+
*/
|
|
177
|
+
it('先読みしていない event は 1 本のリストからでも発火する', () => {
|
|
178
|
+
const { client, fired } = setup();
|
|
179
|
+
client.send('move'); // 先読みで moved のみ
|
|
180
|
+
client.applyState({ moves: 1, charged: 5, stampedAt: 0 }, {
|
|
181
|
+
ack: 1,
|
|
182
|
+
from: 'me',
|
|
183
|
+
// actions 由来 (moved) と serverActions 由来 (charged) が同じ配列で届く
|
|
184
|
+
events: [
|
|
185
|
+
{ name: 'moved', data: {} },
|
|
186
|
+
{ name: 'charged', data: {} },
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
expect(fired).toEqual(['moved', 'charged']);
|
|
190
|
+
});
|
|
191
|
+
/** 他プレイヤーの action なら先読みしていないので全部確定として発火する。 */
|
|
192
|
+
it('他プレイヤーの ack では全部確定として発火する', () => {
|
|
193
|
+
const { client, fired } = setup();
|
|
194
|
+
client.applyState({ moves: 1, charged: 5, stampedAt: 0 }, {
|
|
195
|
+
ack: 1,
|
|
196
|
+
from: 'other',
|
|
197
|
+
events: [
|
|
198
|
+
{ name: 'moved', data: {} },
|
|
199
|
+
{ name: 'charged', data: {} },
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
expect(fired).toEqual(['moved', 'charged']);
|
|
203
|
+
});
|
|
204
|
+
/** predict: false の event は先読みで実行されず、確定時に 1 回だけ実行される。 */
|
|
205
|
+
it('predict: false の event は確定時にだけ実行される', () => {
|
|
206
|
+
const { client, fired } = setup();
|
|
207
|
+
client.send('guess', { willEmit: 'fanfare' });
|
|
208
|
+
expect(fired).toEqual([]);
|
|
209
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [{ name: 'fanfare', data: {} }] });
|
|
210
|
+
expect(fired).toEqual(['fanfare']);
|
|
211
|
+
});
|
|
212
|
+
/**
|
|
213
|
+
* IMPORTANT: 他プレイヤーの action が同じ出来事を起こしても二重実行しない。
|
|
214
|
+
*
|
|
215
|
+
* A と B が同時に同じ行送りを撃つと、サーバーは先着の A だけを通し、B の分は
|
|
216
|
+
* 握り潰す。 B から見て届くのは「A の action の ack」なので、記録を自分の pending に
|
|
217
|
+
* 紐づけていると引き当てられず、先読みで実行済みなのにもう一度実行してしまう。
|
|
218
|
+
* (e2e/prediction-misfire.mjs で実測した回帰)
|
|
219
|
+
*/
|
|
220
|
+
it('他プレイヤー由来の配信でも先読み済みなら二重実行しない', () => {
|
|
221
|
+
const { client, fired } = setup();
|
|
222
|
+
// 自分も撃った (先読みで moved1 を実行)
|
|
223
|
+
client.send('guess', { willEmit: 'moved', data: { n: 1 } });
|
|
224
|
+
expect(fired).toEqual(['moved1']);
|
|
225
|
+
// 先に着いた他プレイヤーの action が同じ出来事を起こして配信されてくる
|
|
226
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
|
|
227
|
+
expect(fired).toEqual(['moved1']);
|
|
228
|
+
});
|
|
229
|
+
/**
|
|
230
|
+
* IMPORTANT: 予測が外れた記録は「その action が ack された時点」で捨てる。
|
|
231
|
+
*
|
|
232
|
+
* 「未確定の action が全部無くなったら捨てる」だけだと、別の action が未確定な間
|
|
233
|
+
* ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回握り潰す。
|
|
234
|
+
* (レビュー指摘の再現)
|
|
235
|
+
*/
|
|
236
|
+
it('別の action が未確定でも、外れた記録はその ack で捨てる', () => {
|
|
237
|
+
const { client, fired } = setup();
|
|
238
|
+
client.send('guess', { willEmit: 'moved', data: { n: 1 } }); // seq 1: 先読みで実行
|
|
239
|
+
client.send('move'); // seq 2: まだ未確定のまま残す
|
|
240
|
+
expect(fired).toEqual(['moved1', 'moved']);
|
|
241
|
+
// seq 1 の ack。サーバーは moved1 を出さなかった = 予測が外れた
|
|
242
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [] });
|
|
243
|
+
// seq 2 が未確定でも、seq 1 の記録は捨てられている
|
|
244
|
+
// → 他プレイヤー由来の本物の moved1 は実行されるべき
|
|
245
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
|
|
246
|
+
expect(fired).toEqual(['moved1', 'moved', 'moved1']);
|
|
247
|
+
});
|
|
248
|
+
/**
|
|
249
|
+
* 予測が外れて実際には起きなかった出来事の記録は、その action の ack で破棄する。
|
|
250
|
+
* 残したままだと、次に本当にその出来事が起きたときに実行されなくなる。
|
|
251
|
+
*/
|
|
252
|
+
it('外れた記録は ack で破棄する', () => {
|
|
253
|
+
const { client, fired } = setup();
|
|
254
|
+
client.send('guess', { willEmit: 'moved', data: { n: 1 } });
|
|
255
|
+
expect(fired).toEqual(['moved1']);
|
|
256
|
+
// 自分の ack。サーバーは何も起こさなかった (予測が外れた)
|
|
257
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 1, from: 'me', events: [] });
|
|
258
|
+
expect(fired).toEqual(['moved1']);
|
|
259
|
+
// 後から本当に起きた同じ出来事は、記録が消えているので実行される
|
|
260
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, { ack: 100, from: 'other', events: [{ name: 'moved', data: { n: 1 } }] });
|
|
261
|
+
expect(fired).toEqual(['moved1', 'moved1']);
|
|
262
|
+
});
|
|
263
|
+
/** 同じ event が 2 回来たら、先読み 1 回分だけを差し引く (多重集合の差)。 */
|
|
264
|
+
it('同じ event が複数回でも先読み分だけ差し引く', () => {
|
|
265
|
+
const { client, fired } = setup();
|
|
266
|
+
client.send('move'); // 先読みで moved 1 回
|
|
267
|
+
client.applyState({ moves: 1, charged: 0, stampedAt: 0 }, {
|
|
268
|
+
ack: 1,
|
|
269
|
+
from: 'me',
|
|
270
|
+
events: [
|
|
271
|
+
{ name: 'moved', data: {} },
|
|
272
|
+
{ name: 'moved', data: {} },
|
|
273
|
+
],
|
|
274
|
+
});
|
|
275
|
+
expect(fired).toEqual(['moved', 'moved']);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
describe('pending キューと巻き戻し', () => {
|
|
279
|
+
/**
|
|
280
|
+
* pending に積まれるのは actions 分だけ。サーバー確定 state を受けたら、
|
|
281
|
+
* 未 ack の actions だけが再適用される (serverActions 分は state に含まれて来る)。
|
|
282
|
+
*/
|
|
283
|
+
it('確定 state の上に未 ack の actions だけを再適用する', () => {
|
|
284
|
+
const { client, states } = setup();
|
|
285
|
+
client.send('move'); // seq 1
|
|
286
|
+
client.send('move'); // seq 2
|
|
287
|
+
// seq 1 だけ確定。charged はサーバー側で加算済みの値が入っている
|
|
288
|
+
client.applyState({ moves: 1, charged: 7, stampedAt: 0 }, { ack: 1, from: 'me' });
|
|
289
|
+
// 確定 (moves:1) + 未 ack の seq 2 を再適用 = moves:2、charged はサーバー値のまま
|
|
290
|
+
expect(latest(states)).toMatchObject({ moves: 2, charged: 7 });
|
|
291
|
+
});
|
|
292
|
+
/** __action_error で該当 seq を除去し、残りを再適用する。 */
|
|
293
|
+
it('rollback は該当 action だけを取り消す', () => {
|
|
294
|
+
const { client, states } = setup();
|
|
295
|
+
client.send('move'); // seq 1
|
|
296
|
+
client.send('move'); // seq 2
|
|
297
|
+
expect(latest(states)).toMatchObject({ moves: 2, charged: 0 });
|
|
298
|
+
client.rollback(1);
|
|
299
|
+
// seq 1 が消えて seq 2 だけ再適用される
|
|
300
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
301
|
+
});
|
|
302
|
+
/** 先行実行で throw した action は pending に積まれず、送信だけ行われる。 */
|
|
303
|
+
it('先行実行が throw した action は pending に積まれない', () => {
|
|
304
|
+
const { client, sent, states } = setup();
|
|
305
|
+
client.send('boom');
|
|
306
|
+
expect(sent).toEqual([{ action: 'boom', seq: 1 }]);
|
|
307
|
+
// pending が空なので、確定 state がそのまま表示される
|
|
308
|
+
client.applyState({ moves: 9, charged: 9, stampedAt: 0 }, {});
|
|
309
|
+
expect(latest(states)).toMatchObject({ moves: 9, charged: 9 });
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
describe('applyDelta', () => {
|
|
313
|
+
/** delta 経路でも events の差し引きは applyState と揃っている。 */
|
|
314
|
+
it('先読み済みを差し引いた残りだけを発火する', () => {
|
|
315
|
+
const { client, fired } = setup();
|
|
316
|
+
client.send('move'); // 先読みで moved
|
|
317
|
+
const ok = client.applyDelta([{ op: 'replace', path: '/charged', value: 3 }], {
|
|
318
|
+
ack: 1,
|
|
319
|
+
from: 'me',
|
|
320
|
+
events: [
|
|
321
|
+
{ name: 'moved', data: {} },
|
|
322
|
+
{ name: 'charged', data: {} },
|
|
323
|
+
],
|
|
324
|
+
});
|
|
325
|
+
expect(ok).toBe(true);
|
|
326
|
+
expect(fired).toEqual(['moved', 'charged']);
|
|
327
|
+
});
|
|
328
|
+
/** patch が当たらないときは events を発火せず false を返す (transport がフル state を再要求する)。 */
|
|
329
|
+
it('patch 適用に失敗したら events を発火せず false を返す', () => {
|
|
330
|
+
const { client, fired } = setup();
|
|
331
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
332
|
+
const ok = client.applyDelta([{ op: 'replace', path: '/missing/deep', value: 1 }], {
|
|
333
|
+
events: [{ name: 'moved', data: {} }],
|
|
334
|
+
});
|
|
335
|
+
expect(ok).toBe(false);
|
|
336
|
+
expect(fired).toEqual([]);
|
|
337
|
+
warn.mockRestore();
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
describe('レビュー指摘の回帰防止', () => {
|
|
341
|
+
/**
|
|
342
|
+
* 旧 serverOnly() が actions に残っている logic では、その handler をクライアントで
|
|
343
|
+
* 先読みしてはいけない。サーバー専用の副作用 (fetch 等) が client でも走ってしまう。
|
|
344
|
+
*/
|
|
345
|
+
it('actions に残った serverOnly() は先読みしない', () => {
|
|
346
|
+
const { client, sent, states } = setup();
|
|
347
|
+
client.send('legacy');
|
|
348
|
+
// state は動かず、送信だけ行われる
|
|
349
|
+
expect(latest(states)).toMatchObject({ moves: 0, charged: 0 });
|
|
350
|
+
expect(sent).toEqual([{ action: 'legacy', seq: 1 }]);
|
|
351
|
+
});
|
|
352
|
+
/** 再適用でも同じ。pending に積まれていても brand 付きなら実行しない。 */
|
|
353
|
+
it('再適用でも serverOnly() を実行しない', () => {
|
|
354
|
+
const { client, states } = setup();
|
|
355
|
+
client.send('move'); // seq 1 (pending へ)
|
|
356
|
+
client.send('legacy'); // seq 2 (pending へは積まれない)
|
|
357
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, {});
|
|
358
|
+
// 確定 state に move だけが再適用され、legacy の +1000 は入らない
|
|
359
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
360
|
+
});
|
|
361
|
+
/**
|
|
362
|
+
* 再適用中に handler が途中まで state を変更してから throw した場合、その部分変更を
|
|
363
|
+
* 残したまま publish してはいけない。
|
|
364
|
+
*/
|
|
365
|
+
it('再適用で throw した handler の部分変更を残さない', () => {
|
|
366
|
+
const { client, states } = setup();
|
|
367
|
+
client.send('move'); // seq 1: moves +1
|
|
368
|
+
client.send('partial'); // seq 2: moves +1 してから throw (pending には積まれない)
|
|
369
|
+
// 送信時点で partial は throw するので pending に入らず、moves は 1 のまま
|
|
370
|
+
expect(latest(states).moves).toBe(1);
|
|
371
|
+
// 確定 state を受けて再適用しても、partial の部分変更は混ざらない
|
|
372
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, {});
|
|
373
|
+
expect(latest(states)).toMatchObject({ moves: 1, charged: 0 });
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
/**
|
|
377
|
+
* ctx.now の配布。
|
|
378
|
+
*
|
|
379
|
+
* actions はクライアント先読みとサーバーで 2 回走るため、handler の中で Date.now() を
|
|
380
|
+
* 読むと端末の時計ズレがそのまま state に入る。ctx.now はサーバーとのクロック
|
|
381
|
+
* オフセットで補正した推定値を配ることでこれを防ぐ。
|
|
382
|
+
*/
|
|
383
|
+
describe('ctx.now', () => {
|
|
384
|
+
/** オフセット未取得なら素のローカル時刻。オフラインでも壊れないこと。 */
|
|
385
|
+
it('サーバー時刻を観測する前はローカル時刻が入る', () => {
|
|
386
|
+
const { client, states } = setup();
|
|
387
|
+
const before = Date.now();
|
|
388
|
+
client.send('move');
|
|
389
|
+
expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
|
|
390
|
+
expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
|
|
391
|
+
});
|
|
392
|
+
/**
|
|
393
|
+
* 端末の時計が大きくズレていても、サーバー時刻の観測でオフセットが補正される。
|
|
394
|
+
* ここでは「サーバーが 1 時間先」を観測させ、ctx.now がそちらへ寄ることを見る。
|
|
395
|
+
*/
|
|
396
|
+
it('サーバー時刻を観測するとオフセット分ずれた値が入る', () => {
|
|
397
|
+
const { client, states } = setup();
|
|
398
|
+
const ONE_HOUR = 3600 * 1000;
|
|
399
|
+
client.observeServerTime(Date.now() + ONE_HOUR);
|
|
400
|
+
client.send('move');
|
|
401
|
+
const stamped = latest(states).stampedAt;
|
|
402
|
+
// 1 時間先へ寄っている (テスト実行のブレを考慮して幅を持たせる)
|
|
403
|
+
expect(stamped).toBeGreaterThan(Date.now() + ONE_HOUR - 5000);
|
|
404
|
+
expect(stamped).toBeLessThan(Date.now() + ONE_HOUR + 5000);
|
|
405
|
+
});
|
|
406
|
+
/** 数値でない / 欠けている場合は無視する (serverTime を持たないメッセージ用)。 */
|
|
407
|
+
it('不正な観測値は無視される', () => {
|
|
408
|
+
const { client, states } = setup();
|
|
409
|
+
client.observeServerTime(undefined);
|
|
410
|
+
client.observeServerTime(Number.NaN);
|
|
411
|
+
const before = Date.now();
|
|
412
|
+
client.send('move');
|
|
413
|
+
expect(latest(states).stampedAt).toBeGreaterThanOrEqual(before);
|
|
414
|
+
expect(latest(states).stampedAt).toBeLessThanOrEqual(Date.now());
|
|
415
|
+
});
|
|
416
|
+
/**
|
|
417
|
+
* IMPORTANT: 型に schedule があるので、先読みされる action からも呼べてしまう。
|
|
418
|
+
* 実体を渡していないと `ctx.schedule is not a function` で先読みが丸ごと落ちる。
|
|
419
|
+
*
|
|
420
|
+
* 予約自体はサーバーだけが持つので先読みでは何もしないが、戻り値 (絶対時刻) は
|
|
421
|
+
* 返す必要がある。シナリオはそれを表示用の endsAt として state に入れるため、
|
|
422
|
+
* undefined を返すと先読み中だけタイマーが消える。
|
|
423
|
+
*/
|
|
424
|
+
it('先読みでも ctx.schedule が呼べて絶対時刻を返す', () => {
|
|
425
|
+
const { client, states, sent } = setup();
|
|
426
|
+
client.send('startTimer');
|
|
427
|
+
// 先読みが落ちていたら pending に積まれず state も変わらない
|
|
428
|
+
expect(sent).toEqual([{ action: 'startTimer', seq: 1 }]);
|
|
429
|
+
const stamped = latest(states).stampedAt;
|
|
430
|
+
expect(stamped).toBeGreaterThanOrEqual(Date.now() + 60000 - 5000);
|
|
431
|
+
expect(stamped).toBeLessThanOrEqual(Date.now() + 60000 + 5000);
|
|
432
|
+
});
|
|
433
|
+
/**
|
|
434
|
+
* IMPORTANT: 再適用 (reconciliation でのやり直し) では初回予測時の now を使い回す。
|
|
435
|
+
*
|
|
436
|
+
* 取り直すと、サーバーから ack が来るたびに handler が別の時刻で走り、
|
|
437
|
+
* timerEndsAt のような値が毎回ズレて秒読みがガタつく。
|
|
438
|
+
*/
|
|
439
|
+
it('再適用でも初回予測時の now を使い回す', () => {
|
|
440
|
+
const { client, states } = setup();
|
|
441
|
+
client.send('move');
|
|
442
|
+
const firstStamp = latest(states).stampedAt;
|
|
443
|
+
// 再適用の直前にオフセットを大きく動かす。now を取り直す実装ならここで
|
|
444
|
+
// stampedAt が 1 時間ジャンプするので、使い回しているかを確実に判別できる。
|
|
445
|
+
client.observeServerTime(Date.now() + 3600 * 1000);
|
|
446
|
+
// 別プレイヤーの ack を受けて再適用させる (自分の pending は残る)
|
|
447
|
+
client.applyState({ moves: 0, charged: 0, stampedAt: 0 }, { ack: 99, from: 'other' });
|
|
448
|
+
expect(latest(states).moves).toBe(1);
|
|
449
|
+
expect(latest(states).stampedAt).toBe(firstStamp);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -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 と
|
|
@@ -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/server-only.d.ts
CHANGED
|
@@ -3,24 +3,31 @@
|
|
|
3
3
|
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
4
4
|
* - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
|
|
5
5
|
*
|
|
6
|
-
* serverOnly()
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `serverOnly()` は `logic.serverActions` に置き換わった互換 API。
|
|
7
|
+
*
|
|
8
|
+
* 新しく書くコードでは `serverActions` フィールドへ直接置く。`actions` の型からは
|
|
9
|
+
* ユニオンが外れたため、`serverOnly()` で wrap した handler を `actions` に入れることは
|
|
10
|
+
* できない (型エラーになる)。
|
|
11
|
+
*
|
|
12
|
+
* `isServerOnlyAction()` はサーバー側テンプレが残す必要がある。R2 に保存済みの古い
|
|
13
|
+
* logic.js は `serverOnly()` の brand を持ったまま `actions` に入っており、新しい
|
|
14
|
+
* テンプレと組み合わさるため、brand を見ないと先読み対象として扱ってしまう。
|
|
9
15
|
*/
|
|
10
|
-
import type { ActionHandler,
|
|
16
|
+
import type { ActionHandler, ServerActionHandler, ServerOnlyAction } from './types.js';
|
|
11
17
|
/**
|
|
12
|
-
*
|
|
18
|
+
* @deprecated `logic.serverActions` に直接書く。
|
|
13
19
|
*
|
|
14
|
-
* 例:
|
|
15
20
|
* ```ts
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* }),
|
|
21
|
-
* }
|
|
21
|
+
* // before
|
|
22
|
+
* actions: { notifyExternal: serverOnly(async (state) => { ... }) }
|
|
23
|
+
* // after
|
|
24
|
+
* serverActions: { notifyExternal: async (state) => { ... } }
|
|
22
25
|
* ```
|
|
23
26
|
*/
|
|
24
|
-
export declare function serverOnly<S>(handler:
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
+
export declare function serverOnly<S>(handler: ServerActionHandler<S>): ServerOnlyAction<S>;
|
|
28
|
+
/**
|
|
29
|
+
* handler が `serverOnly()` で wrap されているか判定する。
|
|
30
|
+
*
|
|
31
|
+
* 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
|
|
32
|
+
*/
|
|
33
|
+
export declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerActionHandler<S> | ServerOnlyAction<S> | undefined): handler is ServerOnlyAction<S>;
|
package/dist/server-only.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* @deprecated `logic.serverActions` に直接書く。
|
|
3
3
|
*
|
|
4
|
-
* 例:
|
|
5
4
|
* ```ts
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* }),
|
|
11
|
-
* }
|
|
5
|
+
* // before
|
|
6
|
+
* actions: { notifyExternal: serverOnly(async (state) => { ... }) }
|
|
7
|
+
* // after
|
|
8
|
+
* serverActions: { notifyExternal: async (state) => { ... } }
|
|
12
9
|
* ```
|
|
13
10
|
*/
|
|
14
11
|
export function serverOnly(handler) {
|
|
15
12
|
return Object.assign(handler, { __serverOnly: true });
|
|
16
13
|
}
|
|
17
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* handler が `serverOnly()` で wrap されているか判定する。
|
|
16
|
+
*
|
|
17
|
+
* 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
|
|
18
|
+
*/
|
|
18
19
|
export function isServerOnlyAction(handler) {
|
|
19
20
|
return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
|
|
20
21
|
}
|