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