@uzuhq/code-sdk 0.7.6 → 0.8.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.
Files changed (53) hide show
  1. package/README.md +52 -9
  2. package/dist/dev-globals.d.ts +12 -27
  3. package/dist/dev-globals.js +0 -15
  4. package/dist/dev-hooks-D6CbhPDP.d.ts +715 -0
  5. package/dist/index.d.ts +158 -66
  6. package/dist/index.js +2178 -416
  7. package/package.json +8 -5
  8. package/dist/action-types.test-d.d.ts +0 -11
  9. package/dist/action-types.test-d.js +0 -101
  10. package/dist/dev-hooks.d.ts +0 -241
  11. package/dist/dev-hooks.js +0 -132
  12. package/dist/dev-hooks.test.d.ts +0 -1
  13. package/dist/dev-hooks.test.js +0 -294
  14. package/dist/dev-prediction-traps.d.ts +0 -32
  15. package/dist/dev-prediction-traps.js +0 -0
  16. package/dist/dev-prediction-traps.test.d.ts +0 -1
  17. package/dist/dev-prediction-traps.test.js +0 -178
  18. package/dist/dev-state-patch.d.ts +0 -81
  19. package/dist/dev-state-patch.js +0 -295
  20. package/dist/dev-state-patch.test.d.ts +0 -1
  21. package/dist/dev-state-patch.test.js +0 -333
  22. package/dist/json-patch.d.ts +0 -7
  23. package/dist/json-patch.js +0 -78
  24. package/dist/random.d.ts +0 -11
  25. package/dist/random.js +0 -34
  26. package/dist/reconnectable-ws.d.ts +0 -60
  27. package/dist/reconnectable-ws.js +0 -229
  28. package/dist/room.d.ts +0 -23
  29. package/dist/room.js +0 -36
  30. package/dist/roster-params.test.d.ts +0 -1
  31. package/dist/roster-params.test.js +0 -86
  32. package/dist/run/local-server-action.d.ts +0 -16
  33. package/dist/run/local-server-action.js +0 -217
  34. package/dist/run/local-server-action.test.d.ts +0 -1
  35. package/dist/run/local-server-action.test.js +0 -242
  36. package/dist/run/optimistic-action-client.d.ts +0 -68
  37. package/dist/run/optimistic-action-client.js +0 -209
  38. package/dist/run/optimistic-action-client.test.d.ts +0 -1
  39. package/dist/run/optimistic-action-client.test.js +0 -430
  40. package/dist/run/server-action.d.ts +0 -16
  41. package/dist/run/server-action.js +0 -181
  42. package/dist/run/server-action.test.d.ts +0 -1
  43. package/dist/run/server-action.test.js +0 -105
  44. package/dist/server-clock.d.ts +0 -29
  45. package/dist/server-clock.js +0 -40
  46. package/dist/server-only.d.ts +0 -33
  47. package/dist/server-only.js +0 -21
  48. package/dist/sync/local.d.ts +0 -8
  49. package/dist/sync/local.js +0 -50
  50. package/dist/sync/online.d.ts +0 -5
  51. package/dist/sync/online.js +0 -165
  52. package/dist/types.d.ts +0 -345
  53. package/dist/types.js +0 -8
@@ -1,181 +0,0 @@
1
- import { ReconnectableWebSocket } from '../reconnectable-ws.js';
2
- import { createOptimisticActionClient } from './optimistic-action-client.js';
3
- export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, seatKind) {
4
- // `kind` は移行期の互換措置。旧 SDK / 旧 play-server が読む wire を変えないために
5
- // 残す。roster は player だけになったので値は常に 'player'。
6
- const toWire = (p) => ({
7
- id: p.id,
8
- name: p.nickname,
9
- iconUrl: p.iconUrl,
10
- characterId: p.characterId,
11
- kind: 'player',
12
- });
13
- const roster = JSON.stringify(players.map(toWire));
14
- const query = new URLSearchParams({
15
- seatId,
16
- nickname: 'Player',
17
- players: roster,
18
- // 移行期: play-server / dev-server が `players=` を読めるようになる前の revision へ
19
- // 繋ぐ経路が残る (JIT 再デプロイ前の焼き込み済みテンプレなど)。旧名も併せて送る。
20
- seats: roster,
21
- });
22
- const wsUrl = `${gameEndpoint}/${roomId}?${query}`;
23
- console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
24
- const ws = new ReconnectableWebSocket(wsUrl, {
25
- onConnectionStateChange: (state) => {
26
- console.log(`[SDK ServerAction] 📡 Connection state: ${state}`);
27
- config.onConnectionStateChange?.(state);
28
- },
29
- // state 系メッセージはバッファしない(即座に stale になるため)
30
- shouldBuffer: (data) => {
31
- try {
32
- const parsed = JSON.parse(data);
33
- return (parsed.type !== '__state' &&
34
- parsed.type !== '__tick' &&
35
- parsed.type !== '__action_result' &&
36
- parsed.type !== '__game_start' &&
37
- parsed.type !== '__tick_delta' &&
38
- parsed.type !== '__action_result_delta');
39
- }
40
- catch {
41
- return true;
42
- }
43
- },
44
- });
45
- /** サーバー seq (delta の連続性チェック用) */
46
- let serverSeq = 0;
47
- /** フル state 再要求中フラグ (多重要求防止) */
48
- let requestStatePending = false;
49
- /** 楽観更新クライアント (transport は本ファイル内で WebSocket に bind) */
50
- const client = createOptimisticActionClient({
51
- logic: config.logic,
52
- playerId: seatId,
53
- // seatKind は接続ごとに固定なのでここで束ねる。楽観更新クライアント側は
54
- // 席種別を一切見ない (state の再適用にしか関心が無い)。
55
- onState: (state, playerId) => config.onState(state, playerId, seatKind),
56
- events: config.events,
57
- sendAction: ({ action, payload, seq }) => {
58
- console.log(`[SDK ServerAction] ➡ send __action action=${action} seq=${seq}`);
59
- ws.send(JSON.stringify({ type: '__action', action, payload, seq }));
60
- },
61
- });
62
- /** サーバーにフル state の再送を要求する */
63
- const requestFullState = () => {
64
- if (requestStatePending)
65
- return;
66
- requestStatePending = true;
67
- console.log(`[SDK ServerAction] 🔄 Requesting full state (seq gap or patch failure)`);
68
- ws.send(JSON.stringify({ type: '__request_state' }));
69
- };
70
- // ─── inputs callback (楽観更新クライアントに委譲) ──────────
71
- config.inputs((type, payload) => {
72
- client.send(type, payload);
73
- });
74
- // ─── メッセージハンドラ ────────────────────────────────
75
- ws.addEventListener('message', (ev) => {
76
- let parsed;
77
- try {
78
- parsed = JSON.parse(ev.data);
79
- }
80
- catch {
81
- return;
82
- }
83
- const msgType = parsed.type;
84
- console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
85
- // サーバーは全ブロードキャストに実時刻を載せる。どのメッセージでもオフセットを
86
- // 更新できるので、tick が回っている限り追加の往復は要らない。
87
- client.observeServerTime(parsed.serverTime);
88
- if (msgType === '__room_init') {
89
- // サーバー (relay-room / sync-room / game-room) はいずれも接続 URL の playerId
90
- // クエリをそのまま `myId` として echo する。つまり parsed.myId は常に引数 playerId と
91
- // 一致するため、client には作成時に playerId を渡しておけば ack 判定 (isMyAck) は
92
- // 正しく動作する。ここで client の playerId を更新する必要はない。
93
- console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
94
- return;
95
- }
96
- if (msgType === '__game_start') {
97
- const seq = parsed.seq ?? 0;
98
- serverSeq = seq;
99
- requestStatePending = false;
100
- // __game_start は new game / reset のシグナル。 applyState だと pending
101
- // actions が新 state に対して再適用され、 reset 直後に isReady=true
102
- // 等の残骸が付いてしまう。 reset() を呼んで pending を明示的にクリアする。
103
- client.reset(parsed.state);
104
- console.log(`[SDK ServerAction] 🎮 Game started (pending cleared)`);
105
- return;
106
- }
107
- // ─── Tick 更新 (フル state) ───────────────────────────
108
- if (msgType === '__tick') {
109
- const evts = parsed.events ?? [];
110
- serverSeq = parsed.seq ?? serverSeq + 1;
111
- requestStatePending = false;
112
- client.applyState(parsed.state, { events: evts });
113
- return;
114
- }
115
- // ─── Tick 更新 (差分パッチ) ──────────────────────────
116
- if (msgType === '__tick_delta') {
117
- const evts = parsed.events ?? [];
118
- const newSeq = parsed.seq ?? serverSeq + 1;
119
- if (newSeq !== serverSeq + 1) {
120
- requestFullState();
121
- return;
122
- }
123
- const ok = client.applyDelta(parsed.patches ?? [], { events: evts });
124
- if (!ok) {
125
- requestFullState();
126
- return;
127
- }
128
- serverSeq = newSeq;
129
- requestStatePending = false;
130
- return;
131
- }
132
- // ─── Action 結果 (フル state) ────────────────────────
133
- if (msgType === '__action_result') {
134
- const ack = parsed.ack;
135
- const from = parsed.from;
136
- const evts = parsed.events ?? [];
137
- serverSeq = parsed.seq ?? serverSeq + 1;
138
- requestStatePending = false;
139
- client.applyState(parsed.state, { ack, from, events: evts });
140
- return;
141
- }
142
- // ─── Action 結果 (差分パッチ) ────────────────────────
143
- if (msgType === '__action_result_delta') {
144
- const ack = parsed.ack;
145
- const from = parsed.from;
146
- const evts = parsed.events ?? [];
147
- const newSeq = parsed.seq ?? serverSeq + 1;
148
- if (newSeq !== serverSeq + 1) {
149
- requestFullState();
150
- return;
151
- }
152
- const ok = client.applyDelta(parsed.patches ?? [], {
153
- ack,
154
- from,
155
- events: evts,
156
- });
157
- if (!ok) {
158
- requestFullState();
159
- return;
160
- }
161
- serverSeq = newSeq;
162
- requestStatePending = false;
163
- return;
164
- }
165
- if (msgType === '__action_error') {
166
- const errorSeq = parsed.seq;
167
- console.warn(`[SDK ServerAction] ⚠️ Action error: ${parsed.error} (seq=${errorSeq})`);
168
- if (errorSeq !== undefined)
169
- client.rollback(errorSeq);
170
- return;
171
- }
172
- // ─── State 復元 (再接続 / late join) ─────────────────
173
- if (msgType === '__state') {
174
- serverSeq = parsed.seq ?? 0;
175
- requestStatePending = false;
176
- console.log(`[SDK ServerAction] 🔄 State restored (reconnect/late join) seq=${serverSeq}`);
177
- client.reset(parsed.state);
178
- return;
179
- }
180
- });
181
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,105 +0,0 @@
1
- /**
2
- * server-action.ts (オンライン接続) の unit test。
3
- *
4
- * 検証対象は「自分の席種別 (`seatKind`) の扱い」に絞る。楽観的更新まわりは
5
- * `optimistic-action-client.test.ts` が持っている。
6
- *
7
- * `seatKind` は roster に載らない観測者が GM ビューと観戦ビューを出し分けるためだけの
8
- * 値で、**client 内で完結する**。サーバーは roster に居ない接続を通すだけで、その席が
9
- * admin か spectator かを知る必要が無い。ここではその 2 点を固定する。
10
- */
11
- import { describe, expect, it, vi, afterEach } from 'vitest';
12
- import { runOnlineServerAction } from './server-action.js';
13
- const logic = {
14
- setup: () => ({ moves: 0 }),
15
- actions: {},
16
- update: () => { },
17
- };
18
- const players = [
19
- { id: 'p1', nickname: 'P1', iconUrl: '' },
20
- { id: 'p2', nickname: 'P2', iconUrl: '' },
21
- ];
22
- const stubWebSocket = () => {
23
- const sockets = [];
24
- class FakeWebSocket {
25
- constructor(url) {
26
- this.url = url;
27
- this.readyState = FakeWebSocket.OPEN;
28
- this.onopen = null;
29
- this.onclose = null;
30
- this.onerror = null;
31
- this.onmessage = null;
32
- sockets.push({
33
- url,
34
- push: (msg) => this.onmessage?.({ data: JSON.stringify(msg) }),
35
- });
36
- // ReconnectableWebSocket は onopen 登録後に発火する必要がある。
37
- setTimeout(() => this.onopen?.(), 0);
38
- }
39
- send() { }
40
- close() {
41
- this.readyState = FakeWebSocket.CLOSED;
42
- }
43
- }
44
- FakeWebSocket.OPEN = 1;
45
- FakeWebSocket.CLOSED = 3;
46
- vi.stubGlobal('WebSocket', FakeWebSocket);
47
- return { sockets };
48
- };
49
- const connect = (seatKind, seatId = 'p1') => {
50
- const { sockets } = stubWebSocket();
51
- const onState = vi.fn();
52
- const config = {
53
- logic,
54
- playerCount: 2,
55
- onState,
56
- inputs: () => { },
57
- };
58
- runOnlineServerAction(config, 'ws://localhost:1234/ws/games/rev1', 'room1', seatId, players, seatKind);
59
- return { sockets, onState };
60
- };
61
- afterEach(() => {
62
- vi.unstubAllGlobals();
63
- });
64
- describe('runOnlineServerAction の seatKind', () => {
65
- /**
66
- * `seatKind` は iframe URL 限定。WebSocket に載せると「サーバーが席種別を知っている」
67
- * ように見えるが、自己申告なので何も保証しない。プロトコルを増やさない。
68
- */
69
- it('WebSocket URL に seatKind を載せない', () => {
70
- const { sockets } = connect('admin', 'admin_0');
71
- expect(sockets).toHaveLength(1);
72
- const url = new URL(sockets[0].url);
73
- expect(url.searchParams.get('seatKind')).toBeNull();
74
- expect(url.searchParams.get('seatId')).toBe('admin_0');
75
- });
76
- /**
77
- * roster (`?players=`) は player 席だけ。観測者は自分が載っていない roster を送る。
78
- */
79
- it('roster には自分が居なくてよい', () => {
80
- const { sockets } = connect('admin', 'admin_0');
81
- const raw = JSON.parse(new URL(sockets[0].url).searchParams.get('players') ?? '[]');
82
- expect(raw.map((s) => s.id)).toEqual(['p1', 'p2']);
83
- });
84
- /**
85
- * 移行期の互換措置。`players=` しか送らないと、JIT 再デプロイ前の焼き込み済み
86
- * テンプレを持つ部屋が roster を pin できず起動しない。旧名にも同じ JSON を載せる。
87
- */
88
- it('旧名 seats にも同じ roster を載せる', () => {
89
- const { sockets } = connect('player');
90
- const params = new URL(sockets[0].url).searchParams;
91
- expect(params.get('seats')).toBe(params.get('players'));
92
- });
93
- /** シナリオが GM ビューと観戦ビューを分けられるよう、自分の席種別だけを渡す。 */
94
- it('onState の第 3 引数に自分の seatKind を渡す', () => {
95
- const { sockets, onState } = connect('admin', 'admin_0');
96
- sockets[0].push({ type: '__game_start', state: { moves: 0 }, seq: 0 });
97
- expect(onState).toHaveBeenCalledWith({ moves: 0 }, 'admin_0', 'admin');
98
- });
99
- /** player 席も同じ経路で自分の席種別を受け取る。 */
100
- it('player 席には player が渡る', () => {
101
- const { sockets, onState } = connect('player');
102
- sockets[0].push({ type: '__game_start', state: { moves: 0 }, seq: 0 });
103
- expect(onState).toHaveBeenCalledWith({ moves: 0 }, 'p1', 'player');
104
- });
105
- });
@@ -1,29 +0,0 @@
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;
@@ -1,40 +0,0 @@
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
- }
@@ -1,33 +0,0 @@
1
- /**
2
- * @docs
3
- * - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
4
- * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
- *
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 を見ないと先読み対象として扱ってしまう。
15
- */
16
- import type { ActionHandler, ServerActionHandler, ServerOnlyAction } from './types.js';
17
- /**
18
- * @deprecated `logic.serverActions` に直接書く。
19
- *
20
- * ```ts
21
- * // before
22
- * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
23
- * // after
24
- * serverActions: { notifyExternal: async (state) => { ... } }
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>;
@@ -1,21 +0,0 @@
1
- /**
2
- * @deprecated `logic.serverActions` に直接書く。
3
- *
4
- * ```ts
5
- * // before
6
- * actions: { notifyExternal: serverOnly(async (state) => { ... }) }
7
- * // after
8
- * serverActions: { notifyExternal: async (state) => { ... } }
9
- * ```
10
- */
11
- export function serverOnly(handler) {
12
- return Object.assign(handler, { __serverOnly: true });
13
- }
14
- /**
15
- * handler が `serverOnly()` で wrap されているか判定する。
16
- *
17
- * 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
18
- */
19
- export function isServerOnlyAction(handler) {
20
- return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
21
- }
@@ -1,8 +0,0 @@
1
- /**
2
- * @docs
3
- * - SDK仕様: docs/docs/uzu_code/play-screen-sdk.md
4
- * - 開発パターン: docs/docs/uzu_code/sdk-guide/patterns.md
5
- */
6
- import type { SyncConfig } from '../types.js';
7
- import type { SyncHandle } from '../dev-hooks.js';
8
- export declare function syncLocal<S>(config: SyncConfig<S>): SyncHandle<S>;
@@ -1,50 +0,0 @@
1
- import { DEFAULT_ICON_URLS } from '../types.js';
2
- import { SERVER_TIME } from '../types.js';
3
- import { applyPatch } from '../json-patch.js';
4
- import { applyJsonMergePatch, applyJsonPatch } from '../dev-state-patch.js';
5
- function resolveLocalTime(ops) {
6
- const now = Date.now();
7
- return ops.map((op) => (op.value === SERVER_TIME ? { ...op, value: now } : op));
8
- }
9
- export function syncLocal(config) {
10
- const { initialState, onState, inputs, events: _events } = config;
11
- const localCount = config.playerCount;
12
- const players = [];
13
- for (let i = 0; i < localCount; i++) {
14
- players.push({
15
- id: `local_${i}`,
16
- nickname: `Player ${i + 1}`,
17
- iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length],
18
- });
19
- }
20
- let state = initialState(players);
21
- const myId = players[0].id;
22
- const patchFn = (ops) => {
23
- applyPatch(state, resolveLocalTime(ops));
24
- onState(state, myId, Date.now());
25
- };
26
- const setFn = (path, value) => {
27
- patchFn([{ op: 'replace', path, value }]);
28
- };
29
- inputs(patchFn, setFn);
30
- onState(state, myId, Date.now());
31
- // Periodic tick to keep serverTime flowing
32
- setInterval(() => {
33
- onState(state, myId, Date.now());
34
- }, 100);
35
- return {
36
- getRawState: () => state,
37
- setRawState: async (next) => {
38
- state = next;
39
- onState(state, myId, Date.now());
40
- },
41
- mergeRawState: async (patch) => {
42
- applyJsonMergePatch(state, patch);
43
- onState(state, myId, Date.now());
44
- },
45
- patchRawState: async (ops) => {
46
- applyJsonPatch(state, ops);
47
- onState(state, myId, Date.now());
48
- },
49
- };
50
- }
@@ -1,5 +0,0 @@
1
- import type { SyncConfig, Seat } from '../types.js';
2
- import { ReconnectableWebSocket } from '../reconnectable-ws.js';
3
- export declare function syncOnline<S>(config: SyncConfig<S>, syncEndpoint: string, roomId: string, playerId: string, hostPlayers: Seat[]): {
4
- ws: ReconnectableWebSocket;
5
- };
@@ -1,165 +0,0 @@
1
- import { SERVER_TIME } from '../types.js';
2
- import { applyPatch } from '../json-patch.js';
3
- import { ReconnectableWebSocket } from '../reconnectable-ws.js';
4
- export function syncOnline(config, syncEndpoint, roomId, playerId, hostPlayers) {
5
- const { initialState, onState, inputs, events: _events } = config;
6
- const wsUrl = `${syncEndpoint}/${roomId}?playerId=${encodeURIComponent(playerId)}`;
7
- console.log(`[SDK Sync] 🔗 Connecting wsUrl=${wsUrl}`);
8
- const ws = new ReconnectableWebSocket(wsUrl, {
9
- onConnectionStateChange: (state) => {
10
- console.log(`[SDK Sync] 📡 Connection state: ${state}`);
11
- config.onConnectionStateChange?.(state);
12
- },
13
- // sync patch / patch_ack はバッファしない(再接続後にサーバーから権威的 state が来るため stale)
14
- shouldBuffer: (data) => {
15
- try {
16
- const parsed = JSON.parse(data);
17
- return parsed.type !== '__patch' && parsed.type !== '__patch_ack';
18
- }
19
- catch {
20
- return true;
21
- }
22
- },
23
- });
24
- const myId = playerId;
25
- const players = hostPlayers;
26
- let state = null;
27
- let gameInitSent = false;
28
- // Server time offset: serverTime ≈ Date.now() + offset
29
- let serverTimeOffset = 0;
30
- // seq ベース整合性管理: サーバーからの patch_ack の連続性を追跡
31
- let localSeq = 0;
32
- // __request_state の連続送信を防ぐフラグ(__state 受信でリセット)
33
- let requestStatePending = false;
34
- const estimateServerTime = () => Date.now() + serverTimeOffset;
35
- const resolveToServerTime = (ops) => {
36
- const now = estimateServerTime();
37
- return ops.map((op) => (op.value === SERVER_TIME ? { ...op, value: now } : op));
38
- };
39
- const sendPatch = (ops) => {
40
- if (!ops || ops.length === 0)
41
- return;
42
- // Optimistic local apply (resolve sentinels to estimated server time)
43
- if (state) {
44
- applyPatch(state, resolveToServerTime(ops));
45
- onState(state, myId, estimateServerTime());
46
- }
47
- // Send original ops (with sentinels) to server — server resolves with its own time
48
- console.log(`[SDK Sync] ➡ send __patch ops=${ops.length}`, JSON.stringify(ops));
49
- ws.send(JSON.stringify({ type: '__patch', ops }));
50
- };
51
- const sendSet = (path, value) => {
52
- sendPatch([{ op: 'replace', path, value }]);
53
- };
54
- inputs(sendPatch, sendSet);
55
- // Periodic tick — estimated server time を使って時間を進める
56
- setInterval(() => {
57
- if (state) {
58
- onState(state, myId, estimateServerTime());
59
- }
60
- }, 100);
61
- const tryInitState = () => {
62
- if (gameInitSent)
63
- return;
64
- gameInitSent = true;
65
- const initState = initialState(players);
66
- console.log(`[SDK Sync] ➡ send __init_state players=${players.length}`);
67
- ws.send(JSON.stringify({ type: '__init_state', state: initState }));
68
- };
69
- ws.addEventListener('message', (ev) => {
70
- let parsed;
71
- try {
72
- parsed = JSON.parse(ev.data);
73
- }
74
- catch {
75
- return;
76
- }
77
- const msgType = parsed.type;
78
- console.log(`[SDK Sync] ⬅ recv type=${msgType}`, JSON.stringify(parsed));
79
- if (msgType === '__room_init') {
80
- console.log(`[SDK Sync] ✅ Room init myId=${myId} players=${players.length}`);
81
- tryInitState();
82
- return;
83
- }
84
- if (msgType === '__reconnected') {
85
- // 再接続成功 — サーバーから __state が後続で届くので再初期化はスキップ
86
- console.log(`[SDK Sync] 🔄 Reconnected myId=${myId}`);
87
- // state が未初期化の場合のみ再送(初回接続中に切断→再接続した場合)
88
- if (!gameInitSent) {
89
- tryInitState();
90
- }
91
- return;
92
- }
93
- if (msgType === '__state_cleared') {
94
- // サーバーで state がクリアされた → ローカルもリセットして再初期化
95
- console.log(`[SDK Sync] 🗑 State cleared, reinitializing`);
96
- state = null;
97
- localSeq = 0;
98
- requestStatePending = false;
99
- gameInitSent = false;
100
- tryInitState();
101
- return;
102
- }
103
- if (msgType === '__patch_failed') {
104
- console.log(`[SDK Sync] ⚠️ Patch failed: ${parsed.reason}`);
105
- config.onPatchFailed?.(parsed.reason);
106
- // サーバーから __state が後続で届く → そちらで state を上書き
107
- return;
108
- }
109
- if (msgType === '__patch_ack') {
110
- if (!state)
111
- return;
112
- const ackSeq = parsed.seq ?? 0;
113
- const ackSenderId = parsed.senderId;
114
- const ackOps = parsed.ops;
115
- const serverTime = parsed.serverTime;
116
- // 送信元が自分の場合は楽観的更新済みなので patch 適用はスキップ、seq と時刻のみ同期
117
- if (ackSenderId === myId) {
118
- localSeq = ackSeq;
119
- serverTimeOffset = serverTime - Date.now();
120
- return;
121
- }
122
- // full state 再要求中は patch 適用をスキップ(壊れた state への追加適用を防止)
123
- if (requestStatePending)
124
- return;
125
- // seq の連続性チェック
126
- if (ackSeq === localSeq + 1) {
127
- // 正常: 差分をローカル state に適用
128
- const ok = applyPatch(state, ackOps);
129
- if (!ok) {
130
- // 楽観的更新でローカル state が diverge → full state でリカバリ
131
- console.log(`[SDK Sync] ⚠️ Local applyPatch failed, requesting full state`);
132
- requestStatePending = true;
133
- ws.send(JSON.stringify({ type: '__request_state' }));
134
- return;
135
- }
136
- localSeq = ackSeq;
137
- serverTimeOffset = serverTime - Date.now();
138
- onState(state, myId, serverTime);
139
- }
140
- else if (ackSeq > localSeq + 1) {
141
- // 欠損検知: full state を再要求
142
- console.log(`[SDK Sync] ⚠️ Seq gap detected: expected=${localSeq + 1} received=${ackSeq}, requesting full state`);
143
- requestStatePending = true;
144
- ws.send(JSON.stringify({ type: '__request_state' }));
145
- }
146
- // ackSeq <= localSeq: 過去の重複 → 無視
147
- return;
148
- }
149
- if (msgType === '__state') {
150
- // Server authoritative state — overwrite local + seq 同期
151
- state = parsed.state;
152
- const serverTime = parsed.serverTime;
153
- localSeq = parsed.seq ?? 0;
154
- requestStatePending = false;
155
- // Calibrate offset from server time
156
- serverTimeOffset = serverTime - Date.now();
157
- console.log(`[SDK Sync] ✅ State received serverTime=${serverTime} offset=${serverTimeOffset} seq=${localSeq}`);
158
- // 再接続後のバッファをクリア(stale patch を破棄)
159
- ws.clearBuffer();
160
- onState(state, myId, serverTime);
161
- return;
162
- }
163
- });
164
- return { ws };
165
- }