@uzuhq/code-sdk 0.3.8

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 自動再接続 WebSocket ラッパー
3
+ *
4
+ * - Exponential backoff + jitter 付き自動再接続
5
+ * - Heartbeat (__ping / __pong) による接続生存確認
6
+ * - 再接続中のメッセージバッファリング
7
+ */
8
+ const DEFAULTS = {
9
+ maxReconnectAttempts: 15,
10
+ baseDelay: 1000,
11
+ maxDelay: 30000,
12
+ jitterFactor: 0.5,
13
+ heartbeatInterval: 25000,
14
+ heartbeatTimeout: 10000,
15
+ maxBufferSize: 50,
16
+ };
17
+ export class ReconnectableWebSocket {
18
+ constructor(url, options = {}) {
19
+ this.ws = null;
20
+ this._state = 'connecting';
21
+ this.reconnectAttempt = 0;
22
+ this.messageBuffer = [];
23
+ this.messageHandlers = [];
24
+ this.heartbeatTimer = null;
25
+ this.heartbeatTimeoutTimer = null;
26
+ this.reconnectTimer = null;
27
+ this.disposed = false;
28
+ this.url = url;
29
+ this.opts = {
30
+ maxReconnectAttempts: options.maxReconnectAttempts ?? DEFAULTS.maxReconnectAttempts,
31
+ baseDelay: options.baseDelay ?? DEFAULTS.baseDelay,
32
+ maxDelay: options.maxDelay ?? DEFAULTS.maxDelay,
33
+ jitterFactor: options.jitterFactor ?? DEFAULTS.jitterFactor,
34
+ heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
35
+ heartbeatTimeout: options.heartbeatTimeout ?? DEFAULTS.heartbeatTimeout,
36
+ maxBufferSize: options.maxBufferSize ?? DEFAULTS.maxBufferSize,
37
+ shouldBuffer: options.shouldBuffer,
38
+ onConnectionStateChange: options.onConnectionStateChange,
39
+ };
40
+ this.connect();
41
+ }
42
+ // ─── Public API ──────────────────────────────────────────
43
+ get connectionState() {
44
+ return this._state;
45
+ }
46
+ /** WebSocket.readyState 互換 (既存コードとの互換用) */
47
+ get readyState() {
48
+ return this.ws?.readyState ?? WebSocket.CLOSED;
49
+ }
50
+ send(data) {
51
+ if (this._state === 'connected' && this.ws?.readyState === WebSocket.OPEN) {
52
+ this.ws.send(data);
53
+ }
54
+ else if (this._state === 'reconnecting') {
55
+ if (this.opts.shouldBuffer && !this.opts.shouldBuffer(data))
56
+ return;
57
+ if (this.messageBuffer.length < this.opts.maxBufferSize) {
58
+ this.messageBuffer.push(data);
59
+ }
60
+ }
61
+ // disconnected / connecting: ドロップ
62
+ }
63
+ addEventListener(type, handler) {
64
+ if (type === 'message') {
65
+ this.messageHandlers.push(handler);
66
+ }
67
+ }
68
+ removeEventListener(type, handler) {
69
+ if (type === 'message') {
70
+ const index = this.messageHandlers.indexOf(handler);
71
+ if (index !== -1) {
72
+ this.messageHandlers.splice(index, 1);
73
+ }
74
+ }
75
+ }
76
+ /** バッファをクリア(権威的 state 受信時に呼ぶ) */
77
+ clearBuffer() {
78
+ this.messageBuffer = [];
79
+ }
80
+ /** 全リソース解放 */
81
+ dispose() {
82
+ this.disposed = true;
83
+ this.stopHeartbeat();
84
+ if (this.reconnectTimer) {
85
+ clearTimeout(this.reconnectTimer);
86
+ this.reconnectTimer = null;
87
+ }
88
+ if (this.ws) {
89
+ this.ws.onopen = null;
90
+ this.ws.onclose = null;
91
+ this.ws.onerror = null;
92
+ this.ws.onmessage = null;
93
+ try {
94
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
95
+ this.ws.close();
96
+ }
97
+ }
98
+ catch (e) {
99
+ console.warn('[ReconnectableWS] Error closing WebSocket:', e);
100
+ }
101
+ this.ws = null;
102
+ }
103
+ }
104
+ // ─── Private ─────────────────────────────────────────────
105
+ connect() {
106
+ if (this.disposed)
107
+ return;
108
+ console.log(`[ReconnectableWS] 🔗 Connecting to ${this.url} (attempt=${this.reconnectAttempt})`);
109
+ const ws = new WebSocket(this.url);
110
+ ws.onopen = () => {
111
+ if (this.disposed)
112
+ return;
113
+ console.log(`[ReconnectableWS] ✅ Connected`);
114
+ this.reconnectAttempt = 0;
115
+ this.setState('connected');
116
+ this.startHeartbeat();
117
+ this.flushBuffer();
118
+ };
119
+ ws.onclose = (ev) => {
120
+ if (this.disposed)
121
+ return;
122
+ // 正常終了 (1000) の場合はリコネクトしない
123
+ if (ev.code === 1000) {
124
+ console.log(`[ReconnectableWS] 🔒 Closed cleanly (code=${ev.code})`);
125
+ return;
126
+ }
127
+ console.log(`[ReconnectableWS] ❌ Closed (code=${ev.code} reason=${ev.reason})`);
128
+ this.stopHeartbeat();
129
+ this.scheduleReconnect();
130
+ };
131
+ ws.onerror = () => {
132
+ if (this.disposed)
133
+ return;
134
+ console.log(`[ReconnectableWS] ⚠️ Error`);
135
+ // onclose が後続で発火するため、ここではリコネクトをトリガーしない
136
+ };
137
+ ws.onmessage = (ev) => {
138
+ if (this.disposed)
139
+ return;
140
+ const data = ev.data;
141
+ // Heartbeat pong のフィルタリング
142
+ if (data === '__pong') {
143
+ this.handlePong();
144
+ return;
145
+ }
146
+ // 通常メッセージをハンドラに配信
147
+ for (const handler of this.messageHandlers) {
148
+ handler(ev);
149
+ }
150
+ };
151
+ this.ws = ws;
152
+ }
153
+ setState(state) {
154
+ if (this._state === state)
155
+ return;
156
+ this._state = state;
157
+ console.log(`[ReconnectableWS] 📡 State: ${state}`);
158
+ this.opts.onConnectionStateChange?.(state);
159
+ }
160
+ scheduleReconnect() {
161
+ if (this.disposed)
162
+ return;
163
+ if (this.reconnectAttempt >= this.opts.maxReconnectAttempts) {
164
+ console.log(`[ReconnectableWS] 🚫 Max reconnect attempts reached (${this.opts.maxReconnectAttempts})`);
165
+ this.setState('disconnected');
166
+ return;
167
+ }
168
+ this.setState('reconnecting');
169
+ const delay = this.getReconnectDelay();
170
+ console.log(`[ReconnectableWS] ⏳ Reconnecting in ${Math.round(delay)}ms (attempt=${this.reconnectAttempt + 1})`);
171
+ this.reconnectTimer = setTimeout(() => {
172
+ this.reconnectAttempt++;
173
+ this.connect();
174
+ }, delay);
175
+ }
176
+ getReconnectDelay() {
177
+ const { baseDelay, maxDelay, jitterFactor } = this.opts;
178
+ const exponentialDelay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempt), maxDelay);
179
+ const jitter = exponentialDelay * jitterFactor * (Math.random() * 2 - 1);
180
+ return Math.max(0, exponentialDelay + jitter);
181
+ }
182
+ flushBuffer() {
183
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN)
184
+ return;
185
+ const buffer = this.messageBuffer;
186
+ this.messageBuffer = [];
187
+ for (const data of buffer) {
188
+ try {
189
+ this.ws.send(data);
190
+ }
191
+ catch (e) {
192
+ console.warn(`[ReconnectableWS] ⚠️ Failed to flush buffered message:`, e);
193
+ }
194
+ }
195
+ if (buffer.length > 0) {
196
+ console.log(`[ReconnectableWS] 📤 Flushed ${buffer.length} buffered messages`);
197
+ }
198
+ }
199
+ // ─── Heartbeat ───────────────────────────────────────────
200
+ startHeartbeat() {
201
+ this.stopHeartbeat();
202
+ this.heartbeatTimer = setInterval(() => {
203
+ if (this.ws?.readyState === WebSocket.OPEN) {
204
+ this.ws.send('__ping');
205
+ this.heartbeatTimeoutTimer = setTimeout(() => {
206
+ console.log(`[ReconnectableWS] 💔 Heartbeat timeout`);
207
+ // 強制切断 → onclose がリコネクトをトリガー
208
+ this.ws?.close(4001, 'heartbeat_timeout');
209
+ }, this.opts.heartbeatTimeout);
210
+ }
211
+ }, this.opts.heartbeatInterval);
212
+ }
213
+ stopHeartbeat() {
214
+ if (this.heartbeatTimer) {
215
+ clearInterval(this.heartbeatTimer);
216
+ this.heartbeatTimer = null;
217
+ }
218
+ if (this.heartbeatTimeoutTimer) {
219
+ clearTimeout(this.heartbeatTimeoutTimer);
220
+ this.heartbeatTimeoutTimer = null;
221
+ }
222
+ }
223
+ handlePong() {
224
+ if (this.heartbeatTimeoutTimer) {
225
+ clearTimeout(this.heartbeatTimeoutTimer);
226
+ this.heartbeatTimeoutTimer = null;
227
+ }
228
+ }
229
+ }
package/dist/room.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ type RoomMessageHandler = (data: Record<string, unknown>) => void;
2
+ /** WebSocket 互換の send/addEventListener インターフェース */
3
+ interface WebSocketLike {
4
+ send(data: string): void;
5
+ addEventListener(type: 'message', handler: (ev: MessageEvent) => void): void;
6
+ }
7
+ /** Room の公開インターフェース。WebSocket / BroadcastChannel どちらでも実装可能。 */
8
+ export interface RoomLike {
9
+ readonly myId: string;
10
+ broadcast(msg: Record<string, unknown>): void;
11
+ send(id: string, msg: Record<string, unknown>): void;
12
+ on(type: string, handler: RoomMessageHandler): void;
13
+ }
14
+ export declare class Room implements RoomLike {
15
+ readonly myId: string;
16
+ private ws;
17
+ private handlers;
18
+ constructor(ws: WebSocketLike, myId: string);
19
+ broadcast(msg: Record<string, unknown>): void;
20
+ send(id: string, msg: Record<string, unknown>): void;
21
+ on(type: string, handler: RoomMessageHandler): void;
22
+ }
23
+ export {};
package/dist/room.js ADDED
@@ -0,0 +1,36 @@
1
+ export class Room {
2
+ constructor(ws, myId) {
3
+ this.handlers = new Map();
4
+ this.ws = ws;
5
+ this.myId = myId;
6
+ this.ws.addEventListener('message', (ev) => {
7
+ let parsed;
8
+ try {
9
+ parsed = JSON.parse(ev.data);
10
+ }
11
+ catch {
12
+ return;
13
+ }
14
+ const type = parsed.type;
15
+ if (!type)
16
+ return;
17
+ const from = parsed.__from;
18
+ console.log(`[SDK Room] ⬅ recv type=${type} from=${from}`, JSON.stringify(parsed));
19
+ const handlers = this.handlers.get(type) || [];
20
+ handlers.forEach((h) => h({ ...parsed, __from: from }));
21
+ });
22
+ }
23
+ broadcast(msg) {
24
+ console.log(`[SDK Room] ➡ broadcast type=${msg.type}`, JSON.stringify(msg));
25
+ this.ws.send(JSON.stringify(msg));
26
+ }
27
+ send(id, msg) {
28
+ console.log(`[SDK Room] ➡ send to=${id} type=${msg.type}`, JSON.stringify(msg));
29
+ this.ws.send(JSON.stringify({ ...msg, __to: id }));
30
+ }
31
+ on(type, handler) {
32
+ if (!this.handlers.has(type))
33
+ this.handlers.set(type, []);
34
+ this.handlers.get(type).push(handler);
35
+ }
36
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/play_screen_v3/connection-method/arch3-authority.md
4
+ * - SDK仕様: docs/docs/play_screen_v3/play-screen-sdk.md
5
+ *
6
+ * ブラウザ内 LocalGameRoom — GameRoom DO の動作をサーバーレスで再現する。
7
+ * server_url がない場合に vite dev だけで ServerAction シナリオをローカル実行する。
8
+ *
9
+ * online (`runOnlineServerAction`) と挙動を揃えるため、standard handler は同期で実行し
10
+ * `sendAction()` 直後に `onState()` を同期発火する (楽観的更新だけで完結する仕様)。
11
+ * serverOnly handler のみ `await` で非同期実行し、完了後に `onState()` を呼ぶ。
12
+ */
13
+ import type { GameConfig } from '../types.js';
14
+ import type { RunHandle } from '../dev-hooks.js';
15
+ export declare function runLocalServerAction<S>(config: GameConfig<S>): RunHandle<S>;
@@ -0,0 +1,97 @@
1
+ import { DEFAULT_ICON_URLS } from '../types.js';
2
+ import { SeededRandomImpl } from '../random.js';
3
+ import { isServerOnlyAction } from '../server-only.js';
4
+ import { applyJsonMergePatch, applyJsonPatch } from '../dev-state-patch.js';
5
+ export function runLocalServerAction(config) {
6
+ const { logic, onState, inputs, events } = config;
7
+ const tickRate = logic.tickRate ?? 0; // DO と同じデフォルト(0=tickなし)
8
+ const seed = Math.floor(Math.random() * 0xffffffff);
9
+ const random = new SeededRandomImpl(seed);
10
+ const players = Array.from({ length: config.playerCount }, (_, i) => ({
11
+ id: `local_${i}`,
12
+ nickname: `Player ${i + 1}`,
13
+ iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length],
14
+ kind: 'player',
15
+ }));
16
+ const myId = players[0].id;
17
+ // イベント収集→一括配信(DO と同じパターン)
18
+ const dispatchEvents = (evts) => {
19
+ for (const e of evts) {
20
+ events?.[e.name]?.(e.data);
21
+ }
22
+ };
23
+ // setRawState で全置換できるよう let。closures は名前参照なので最新束縛を読む。
24
+ let state = logic.setup(players, random);
25
+ let tick = 0;
26
+ const playerInputs = {};
27
+ // Action 処理。standard handler は同期実行で `sendAction()` 直後の同期 onState を
28
+ // 保証する (online の楽観的更新と同じ挙動)。serverOnly handler は `Promise<void>`
29
+ // を返しうるので `await` で実行し、完了後に events と onState を発火する。
30
+ // どちらも 1 回しか実行しない (online の「楽観 → サーバー確定」の 2 段階は再現しない)。
31
+ const dispatchAction = (type, payload) => {
32
+ const handler = logic.actions[type];
33
+ if (!handler)
34
+ return;
35
+ const actionEvents = [];
36
+ const actionEmit = (name, data) => actionEvents.push({ name, data: data ?? {} });
37
+ if (isServerOnlyAction(handler)) {
38
+ void (async () => {
39
+ try {
40
+ await handler(state, payload ?? {}, myId, actionEmit, { tick });
41
+ }
42
+ catch (err) {
43
+ console.warn('[SDK LocalServerAction] Action error:', err);
44
+ return;
45
+ }
46
+ dispatchEvents(actionEvents);
47
+ onState(state, myId);
48
+ })();
49
+ return;
50
+ }
51
+ try {
52
+ handler(state, payload ?? {}, myId, actionEmit, { tick });
53
+ }
54
+ catch (err) {
55
+ console.warn('[SDK LocalServerAction] Action error:', err);
56
+ return;
57
+ }
58
+ dispatchEvents(actionEvents);
59
+ onState(state, myId);
60
+ };
61
+ inputs(dispatchAction);
62
+ onState(state, myId);
63
+ // Tick ループ(tickRate > 0 の場合のみ、DO と同じ)
64
+ if (tickRate > 0) {
65
+ setInterval(() => {
66
+ const tickEvents = [];
67
+ const tickEmit = (name, data) => tickEvents.push({ name, data: data ?? {} });
68
+ try {
69
+ logic.update(state, { random, tick, emit: tickEmit, playerInputs });
70
+ }
71
+ catch (err) {
72
+ console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
73
+ tick++;
74
+ return;
75
+ }
76
+ tick++;
77
+ dispatchEvents(tickEvents);
78
+ onState(state, myId);
79
+ }, 1000 / tickRate);
80
+ }
81
+ return {
82
+ getRawState: () => state,
83
+ setRawState: async (next) => {
84
+ state = next;
85
+ onState(state, myId);
86
+ },
87
+ mergeRawState: async (patch) => {
88
+ applyJsonMergePatch(state, patch);
89
+ onState(state, myId);
90
+ },
91
+ patchRawState: async (ops) => {
92
+ applyJsonPatch(state, ops);
93
+ onState(state, myId);
94
+ },
95
+ sendAction: dispatchAction,
96
+ };
97
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/play_screen_v3/connection-method/arch3-authority.md
4
+ * - 開発パターン: docs/docs/play_screen_v3/sdk-guide/patterns.md
5
+ *
6
+ * ServerAction の楽観的更新クライアント — online / emulator 共通ロジック。
7
+ *
8
+ * トランスポート (WebSocket / BroadcastChannel) と上位ロジック (楽観更新 / pending
9
+ * キュー / ack ベースの確定 / rollback) を分離し、3 モードで挙動が揃うことを実装で保証する。
10
+ *
11
+ * - `send(type, payload)`: standard handler は同期で先行実行し pending キューへ。
12
+ * serverOnly handler は skip して transport にだけ送る。
13
+ * - `applyState(state, { ack, from, events })`: フル state を受信した時に呼ぶ。
14
+ * - `applyDelta(patches, { ack, from, events })`: JSON Patch を受信した時に呼ぶ
15
+ * (適用失敗時は false を返すので transport 側でフル state を再要求する)。
16
+ * - `rollback(seq)`: `__action_error` 受信時に該当 action を pending から除去して再適用。
17
+ * - `reset(state)`: 再接続後の state 復元用 (pending を全クリア)。
18
+ */
19
+ import type { GameLogic } from '../types.js';
20
+ import type { Operation } from '../json-patch.js';
21
+ export interface EventEntry {
22
+ name: string;
23
+ data: Record<string, unknown>;
24
+ }
25
+ export interface ConfirmOptions {
26
+ /** transport から受け取った action ack (確定された pending action の seq) */
27
+ ack?: number;
28
+ /** ack の送信元 player id。自分の pending と一致した時だけ events を skip する */
29
+ from?: string;
30
+ /** サーバー (or 仮想サーバー) が dispatch した events */
31
+ events?: EventEntry[];
32
+ }
33
+ export interface OptimisticActionClientConfig<S> {
34
+ logic: GameLogic<S>;
35
+ playerId: string;
36
+ onState: (state: S, playerId: string) => void;
37
+ events?: Record<string, (data: Record<string, unknown>) => void>;
38
+ /** action を transport に流すコールバック */
39
+ sendAction: (msg: {
40
+ action: string;
41
+ payload: any;
42
+ seq: number;
43
+ }) => void;
44
+ }
45
+ export interface OptimisticActionClient<S> {
46
+ /** input から呼ばれる action dispatch (楽観更新 + transport 送信) */
47
+ send(type: string, payload?: any): void;
48
+ /** 仮想サーバー / DO からフル state を受信した時に呼ぶ */
49
+ applyState(state: S, options?: ConfirmOptions): void;
50
+ /** DO から JSON Patch delta を受信した時に呼ぶ。適用失敗時は false (transport で再要求) */
51
+ applyDelta(patches: Operation[], options?: ConfirmOptions): boolean;
52
+ /** `__action_error` 受信時に該当 action をロールバック */
53
+ rollback(seq: number): void;
54
+ /** 再接続時の state 復元 (pending を全クリア) */
55
+ reset(state: S): void;
56
+ }
57
+ export declare function createOptimisticActionClient<S>(config: OptimisticActionClientConfig<S>): OptimisticActionClient<S>;
@@ -0,0 +1,119 @@
1
+ import { applyPatch } from '../json-patch.js';
2
+ import { isServerOnlyAction } from '../server-only.js';
3
+ export function createOptimisticActionClient(config) {
4
+ const { logic, playerId, onState, events, sendAction } = config;
5
+ /** サーバー確定 state (楽観的更新のベース) */
6
+ let confirmedState = null;
7
+ /** 表示用 state (pending actions 適用済み) */
8
+ let displayState = null;
9
+ /** クライアント側の action 通番 */
10
+ let actionSeq = 0;
11
+ /** 送信済みだがサーバー未確認の action キュー */
12
+ const pendingActions = [];
13
+ const emit = (eventName, data) => {
14
+ events?.[eventName]?.(data ?? {});
15
+ };
16
+ /** 再適用時はイベントを発火しない (送信時に既に発火済み) */
17
+ const noopEmit = () => { };
18
+ const dispatchEvents = (evts) => {
19
+ for (const e of evts) {
20
+ events?.[e.name]?.(e.data);
21
+ }
22
+ };
23
+ /**
24
+ * confirmedState をベースに pending actions を再適用して displayState を更新する。
25
+ * 再適用失敗した action はキューから除去する。
26
+ */
27
+ const reapplyPendingActions = () => {
28
+ if (confirmedState === null)
29
+ return;
30
+ displayState = structuredClone(confirmedState);
31
+ let i = 0;
32
+ while (i < pendingActions.length) {
33
+ const { action, payload } = pendingActions[i];
34
+ const handler = logic.actions[action];
35
+ if (!handler) {
36
+ pendingActions.splice(i, 1);
37
+ continue;
38
+ }
39
+ try {
40
+ // tick はサーバー側でのみ正確に管理される。再適用ではサーバー tick が不明のため 0 を使う。
41
+ handler(displayState, payload, playerId, noopEmit, { tick: 0 });
42
+ i++;
43
+ }
44
+ catch {
45
+ pendingActions.splice(i, 1);
46
+ }
47
+ }
48
+ onState(displayState, playerId);
49
+ };
50
+ /**
51
+ * 自分が出した pending action が ack されたかを判定し、events 重複排除と
52
+ * pending キューの掃除を行う。
53
+ */
54
+ const handleAck = (ack, from, evts) => {
55
+ const isMyAck = from === playerId && ack !== undefined && pendingActions.some((p) => p.seq === ack);
56
+ if (!isMyAck) {
57
+ dispatchEvents(evts);
58
+ }
59
+ if (from === playerId && ack !== undefined) {
60
+ while (pendingActions.length > 0 && pendingActions[0].seq <= ack) {
61
+ pendingActions.shift();
62
+ }
63
+ }
64
+ };
65
+ return {
66
+ send(type, payload) {
67
+ actionSeq++;
68
+ const seq = actionSeq;
69
+ // serverOnly handler は先行実行を skip。pending にも積まないので、ack 受信時の
70
+ // isMyAck 判定で false となり、サーバー発の events が普通に emit される。
71
+ const handler = logic.actions[type];
72
+ if (handler && !isServerOnlyAction(handler) && displayState !== null) {
73
+ try {
74
+ handler(displayState, payload ?? {}, playerId, emit, { tick: 0 });
75
+ pendingActions.push({ seq, action: type, payload: payload ?? {} });
76
+ onState(displayState, playerId);
77
+ }
78
+ catch {
79
+ // ローカル実行失敗 → 楽観的更新せずサーバーに送るだけ
80
+ }
81
+ }
82
+ sendAction({ action: type, payload: payload ?? {}, seq });
83
+ },
84
+ applyState(state, options = {}) {
85
+ handleAck(options.ack, options.from, options.events ?? []);
86
+ confirmedState = state;
87
+ reapplyPendingActions();
88
+ },
89
+ applyDelta(patches, options = {}) {
90
+ if (confirmedState === null)
91
+ return false;
92
+ // patch の適用可否を確定してから events 発火と pending 掃除を行う。適用失敗時は
93
+ // events を発火せず false を返し、transport 側でフル state を再要求する。これにより
94
+ // 「delta を適用できない (seq gap / patch 失敗) なら events を発火せずフル state を
95
+ // 要求する」という挙動が両経路で揃う (seq gap は transport 側で早期 return)。
96
+ const cloned = structuredClone(confirmedState);
97
+ const ok = applyPatch(cloned, patches);
98
+ if (!ok)
99
+ return false;
100
+ confirmedState = cloned;
101
+ handleAck(options.ack, options.from, options.events ?? []);
102
+ reapplyPendingActions();
103
+ return true;
104
+ },
105
+ rollback(seq) {
106
+ const idx = pendingActions.findIndex((p) => p.seq === seq);
107
+ if (idx !== -1) {
108
+ pendingActions.splice(idx, 1);
109
+ reapplyPendingActions();
110
+ }
111
+ },
112
+ reset(state) {
113
+ pendingActions.length = 0;
114
+ confirmedState = state;
115
+ displayState = structuredClone(state);
116
+ onState(displayState, playerId);
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @docs
3
+ * - ServerAction仕様: docs/docs/play_screen_v3/connection-method/arch3-authority.md
4
+ * - SDK仕様: docs/docs/play_screen_v3/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 } from '../types.js';
16
+ export declare function runOnlineServerAction<S>(config: GameConfig<S>, gameEndpoint: string, roomId: string, seatId: string, seats: Seat[]): void;