@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.
- package/LICENSE +21 -0
- package/README.md +573 -0
- package/dist/dev-globals.d.ts +28 -0
- package/dist/dev-globals.js +16 -0
- package/dist/dev-hooks.d.ts +233 -0
- package/dist/dev-hooks.js +130 -0
- package/dist/dev-hooks.test.d.ts +1 -0
- package/dist/dev-hooks.test.js +294 -0
- package/dist/dev-state-patch.d.ts +81 -0
- package/dist/dev-state-patch.js +295 -0
- package/dist/dev-state-patch.test.d.ts +1 -0
- package/dist/dev-state-patch.test.js +333 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +440 -0
- package/dist/json-patch.d.ts +7 -0
- package/dist/json-patch.js +78 -0
- package/dist/random.d.ts +11 -0
- package/dist/random.js +34 -0
- package/dist/reconnectable-ws.d.ts +60 -0
- package/dist/reconnectable-ws.js +229 -0
- package/dist/room.d.ts +23 -0
- package/dist/room.js +36 -0
- package/dist/run/local-server-action.d.ts +15 -0
- package/dist/run/local-server-action.js +97 -0
- package/dist/run/optimistic-action-client.d.ts +57 -0
- package/dist/run/optimistic-action-client.js +119 -0
- package/dist/run/server-action.d.ts +16 -0
- package/dist/run/server-action.js +170 -0
- package/dist/server-only.d.ts +26 -0
- package/dist/server-only.js +20 -0
- package/dist/sync/local.d.ts +8 -0
- package/dist/sync/local.js +51 -0
- package/dist/sync/online.d.ts +5 -0
- package/dist/sync/online.js +165 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.js +8 -0
- package/package.json +34 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ReconnectableWebSocket } from '../reconnectable-ws.js';
|
|
2
|
+
import { createOptimisticActionClient } from './optimistic-action-client.js';
|
|
3
|
+
export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seats) {
|
|
4
|
+
const toWire = (p) => ({
|
|
5
|
+
id: p.id,
|
|
6
|
+
name: p.nickname,
|
|
7
|
+
iconUrl: p.iconUrl,
|
|
8
|
+
characterId: p.characterId,
|
|
9
|
+
kind: p.kind,
|
|
10
|
+
});
|
|
11
|
+
const query = new URLSearchParams({
|
|
12
|
+
seatId,
|
|
13
|
+
nickname: 'Player',
|
|
14
|
+
seats: JSON.stringify(seats.map(toWire)),
|
|
15
|
+
});
|
|
16
|
+
const wsUrl = `${gameEndpoint}/${roomId}?${query}`;
|
|
17
|
+
console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
|
|
18
|
+
const ws = new ReconnectableWebSocket(wsUrl, {
|
|
19
|
+
onConnectionStateChange: (state) => {
|
|
20
|
+
console.log(`[SDK ServerAction] 📡 Connection state: ${state}`);
|
|
21
|
+
config.onConnectionStateChange?.(state);
|
|
22
|
+
},
|
|
23
|
+
// state 系メッセージはバッファしない(即座に stale になるため)
|
|
24
|
+
shouldBuffer: (data) => {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(data);
|
|
27
|
+
return (parsed.type !== '__state' &&
|
|
28
|
+
parsed.type !== '__tick' &&
|
|
29
|
+
parsed.type !== '__action_result' &&
|
|
30
|
+
parsed.type !== '__game_start' &&
|
|
31
|
+
parsed.type !== '__tick_delta' &&
|
|
32
|
+
parsed.type !== '__action_result_delta');
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
/** サーバー seq (delta の連続性チェック用) */
|
|
40
|
+
let serverSeq = 0;
|
|
41
|
+
/** フル state 再要求中フラグ (多重要求防止) */
|
|
42
|
+
let requestStatePending = false;
|
|
43
|
+
/** 楽観更新クライアント (transport は本ファイル内で WebSocket に bind) */
|
|
44
|
+
const client = createOptimisticActionClient({
|
|
45
|
+
logic: config.logic,
|
|
46
|
+
playerId: seatId,
|
|
47
|
+
onState: config.onState,
|
|
48
|
+
events: config.events,
|
|
49
|
+
sendAction: ({ action, payload, seq }) => {
|
|
50
|
+
console.log(`[SDK ServerAction] ➡ send __action action=${action} seq=${seq}`);
|
|
51
|
+
ws.send(JSON.stringify({ type: '__action', action, payload, seq }));
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
/** サーバーにフル state の再送を要求する */
|
|
55
|
+
const requestFullState = () => {
|
|
56
|
+
if (requestStatePending)
|
|
57
|
+
return;
|
|
58
|
+
requestStatePending = true;
|
|
59
|
+
console.log(`[SDK ServerAction] 🔄 Requesting full state (seq gap or patch failure)`);
|
|
60
|
+
ws.send(JSON.stringify({ type: '__request_state' }));
|
|
61
|
+
};
|
|
62
|
+
// ─── inputs callback (楽観更新クライアントに委譲) ──────────
|
|
63
|
+
config.inputs((type, payload) => {
|
|
64
|
+
client.send(type, payload);
|
|
65
|
+
});
|
|
66
|
+
// ─── メッセージハンドラ ────────────────────────────────
|
|
67
|
+
ws.addEventListener('message', (ev) => {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(ev.data);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const msgType = parsed.type;
|
|
76
|
+
console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
|
|
77
|
+
if (msgType === '__room_init') {
|
|
78
|
+
// サーバー (relay-room / sync-room / game-room) はいずれも接続 URL の playerId
|
|
79
|
+
// クエリをそのまま `myId` として echo する。つまり parsed.myId は常に引数 playerId と
|
|
80
|
+
// 一致するため、client には作成時に playerId を渡しておけば ack 判定 (isMyAck) は
|
|
81
|
+
// 正しく動作する。ここで client の playerId を更新する必要はない。
|
|
82
|
+
console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (msgType === '__game_start') {
|
|
86
|
+
const seq = parsed.seq ?? 0;
|
|
87
|
+
serverSeq = seq;
|
|
88
|
+
requestStatePending = false;
|
|
89
|
+
// __game_start は new game / reset のシグナル。 applyState だと pending
|
|
90
|
+
// actions が新 state に対して再適用され、 reset 直後に isReady=true
|
|
91
|
+
// 等の残骸が付いてしまう。 reset() を呼んで pending を明示的にクリアする。
|
|
92
|
+
client.reset(parsed.state);
|
|
93
|
+
console.log(`[SDK ServerAction] 🎮 Game started (pending cleared)`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// ─── Tick 更新 (フル state) ───────────────────────────
|
|
97
|
+
if (msgType === '__tick') {
|
|
98
|
+
const evts = parsed.events ?? [];
|
|
99
|
+
serverSeq = parsed.seq ?? serverSeq + 1;
|
|
100
|
+
requestStatePending = false;
|
|
101
|
+
client.applyState(parsed.state, { events: evts });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// ─── Tick 更新 (差分パッチ) ──────────────────────────
|
|
105
|
+
if (msgType === '__tick_delta') {
|
|
106
|
+
const evts = parsed.events ?? [];
|
|
107
|
+
const newSeq = parsed.seq ?? serverSeq + 1;
|
|
108
|
+
if (newSeq !== serverSeq + 1) {
|
|
109
|
+
requestFullState();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const ok = client.applyDelta(parsed.patches ?? [], { events: evts });
|
|
113
|
+
if (!ok) {
|
|
114
|
+
requestFullState();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
serverSeq = newSeq;
|
|
118
|
+
requestStatePending = false;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
// ─── Action 結果 (フル state) ────────────────────────
|
|
122
|
+
if (msgType === '__action_result') {
|
|
123
|
+
const ack = parsed.ack;
|
|
124
|
+
const from = parsed.from;
|
|
125
|
+
const evts = parsed.events ?? [];
|
|
126
|
+
serverSeq = parsed.seq ?? serverSeq + 1;
|
|
127
|
+
requestStatePending = false;
|
|
128
|
+
client.applyState(parsed.state, { ack, from, events: evts });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// ─── Action 結果 (差分パッチ) ────────────────────────
|
|
132
|
+
if (msgType === '__action_result_delta') {
|
|
133
|
+
const ack = parsed.ack;
|
|
134
|
+
const from = parsed.from;
|
|
135
|
+
const evts = parsed.events ?? [];
|
|
136
|
+
const newSeq = parsed.seq ?? serverSeq + 1;
|
|
137
|
+
if (newSeq !== serverSeq + 1) {
|
|
138
|
+
requestFullState();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const ok = client.applyDelta(parsed.patches ?? [], {
|
|
142
|
+
ack,
|
|
143
|
+
from,
|
|
144
|
+
events: evts,
|
|
145
|
+
});
|
|
146
|
+
if (!ok) {
|
|
147
|
+
requestFullState();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
serverSeq = newSeq;
|
|
151
|
+
requestStatePending = false;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (msgType === '__action_error') {
|
|
155
|
+
const errorSeq = parsed.seq;
|
|
156
|
+
console.warn(`[SDK ServerAction] ⚠️ Action error: ${parsed.error} (seq=${errorSeq})`);
|
|
157
|
+
if (errorSeq !== undefined)
|
|
158
|
+
client.rollback(errorSeq);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// ─── State 復元 (再接続 / late join) ─────────────────
|
|
162
|
+
if (msgType === '__state') {
|
|
163
|
+
serverSeq = parsed.seq ?? 0;
|
|
164
|
+
requestStatePending = false;
|
|
165
|
+
console.log(`[SDK ServerAction] 🔄 State restored (reconnect/late join) seq=${serverSeq}`);
|
|
166
|
+
client.reset(parsed.state);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
* serverOnly() で wrap した handler はクライアント側の楽観的更新(先行実行)を
|
|
7
|
+
* スキップし、サーバー側でだけ実行される。fetch などの副作用付き処理を安全に
|
|
8
|
+
* 書ける。
|
|
9
|
+
*/
|
|
10
|
+
import type { ActionHandler, ServerOnlyAction, ServerOnlyActionHandlerFn } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* server-only action handler を作る。
|
|
13
|
+
*
|
|
14
|
+
* 例:
|
|
15
|
+
* ```ts
|
|
16
|
+
* actions: {
|
|
17
|
+
* notifyExternal: serverOnly(async (state, payload, playerId) => {
|
|
18
|
+
* await fetch('https://example.com/notify', { ... });
|
|
19
|
+
* state.notifiedAt = Date.now();
|
|
20
|
+
* }),
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare function serverOnly<S>(handler: ServerOnlyActionHandlerFn<S>): ServerOnlyAction<S>;
|
|
25
|
+
/** handler が serverOnly() で wrap されているか判定する。 */
|
|
26
|
+
export declare function isServerOnlyAction<S>(handler: ActionHandler<S> | ServerOnlyAction<S> | undefined): handler is ServerOnlyAction<S>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server-only action handler を作る。
|
|
3
|
+
*
|
|
4
|
+
* 例:
|
|
5
|
+
* ```ts
|
|
6
|
+
* actions: {
|
|
7
|
+
* notifyExternal: serverOnly(async (state, payload, playerId) => {
|
|
8
|
+
* await fetch('https://example.com/notify', { ... });
|
|
9
|
+
* state.notifiedAt = Date.now();
|
|
10
|
+
* }),
|
|
11
|
+
* }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function serverOnly(handler) {
|
|
15
|
+
return Object.assign(handler, { __serverOnly: true });
|
|
16
|
+
}
|
|
17
|
+
/** handler が serverOnly() で wrap されているか判定する。 */
|
|
18
|
+
export function isServerOnlyAction(handler) {
|
|
19
|
+
return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - SDK仕様: docs/docs/play_screen_v3/play-screen-sdk.md
|
|
4
|
+
* - 開発パターン: docs/docs/play_screen_v3/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>;
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
kind: 'player',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
let state = initialState(players);
|
|
22
|
+
const myId = players[0].id;
|
|
23
|
+
const patchFn = (ops) => {
|
|
24
|
+
applyPatch(state, resolveLocalTime(ops));
|
|
25
|
+
onState(state, myId, Date.now());
|
|
26
|
+
};
|
|
27
|
+
const setFn = (path, value) => {
|
|
28
|
+
patchFn([{ op: 'replace', path, value }]);
|
|
29
|
+
};
|
|
30
|
+
inputs(patchFn, setFn);
|
|
31
|
+
onState(state, myId, Date.now());
|
|
32
|
+
// Periodic tick to keep serverTime flowing
|
|
33
|
+
setInterval(() => {
|
|
34
|
+
onState(state, myId, Date.now());
|
|
35
|
+
}, 100);
|
|
36
|
+
return {
|
|
37
|
+
getRawState: () => state,
|
|
38
|
+
setRawState: async (next) => {
|
|
39
|
+
state = next;
|
|
40
|
+
onState(state, myId, Date.now());
|
|
41
|
+
},
|
|
42
|
+
mergeRawState: async (patch) => {
|
|
43
|
+
applyJsonMergePatch(state, patch);
|
|
44
|
+
onState(state, myId, Date.now());
|
|
45
|
+
},
|
|
46
|
+
patchRawState: async (ops) => {
|
|
47
|
+
applyJsonPatch(state, ops);
|
|
48
|
+
onState(state, myId, Date.now());
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @docs
|
|
3
|
+
* - SDK仕様: docs/docs/play_screen_v3/play-screen-sdk.md
|
|
4
|
+
* - APIリファレンス: docs/docs/play_screen_v3/sdk-guide/api-reference.md
|
|
5
|
+
* - 開発パターン: docs/docs/play_screen_v3/sdk-guide/patterns.md
|
|
6
|
+
* - 外部 automation API: docs/docs/play_screen_v3/sdk-guide/dev-hooks.md
|
|
7
|
+
*/
|
|
8
|
+
/** @deprecated 旧フラット形式。新コードでは BridgeMessage を使用 */
|
|
9
|
+
export type PlayScreenMessage = {
|
|
10
|
+
type: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
/** ブリッジメッセージのチャンネル */
|
|
14
|
+
export type BridgeChannel = 'sdk' | 'game';
|
|
15
|
+
/** ワイヤーフォーマット: { channel, type, payload, playerId? } */
|
|
16
|
+
export interface BridgeMessage {
|
|
17
|
+
channel: BridgeChannel;
|
|
18
|
+
type: string;
|
|
19
|
+
payload: Record<string, unknown>;
|
|
20
|
+
/**
|
|
21
|
+
* 送信元プレイヤーの ID(ゲーム → ホスト方向のみ)。
|
|
22
|
+
* Web エミュレータでは複数プレイヤーの iframe が同一オリジンになり
|
|
23
|
+
* postMessage の送信元 iframe を区別できないため、ホストはこの値で
|
|
24
|
+
* 自分宛メッセージかを判定する。
|
|
25
|
+
*
|
|
26
|
+
* Flutter ホスト(QueryParamsBuilder)と dev ハーネスは URL に必ず
|
|
27
|
+
* playerId を付与するため、ゲーム → ホスト方向では常に存在する。
|
|
28
|
+
* optional なのはホスト → ゲーム方向のメッセージが持たないため
|
|
29
|
+
* (受信側は playerId 無し = 旧 SDK ビルドとして扱う)。
|
|
30
|
+
*/
|
|
31
|
+
playerId?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* セッションの席種。
|
|
35
|
+
* - player: ゲームの参加者 (キャラクターを担当する)
|
|
36
|
+
* - spectator: 観戦者 (state を閲覧するだけで、ゲームの席は占めない)
|
|
37
|
+
* - admin: 進行管理席 (観戦 + GM 操作。dev harness のテストプレイ用)
|
|
38
|
+
*/
|
|
39
|
+
export type SeatKind = 'player' | 'spectator' | 'admin';
|
|
40
|
+
export interface Seat {
|
|
41
|
+
id: string;
|
|
42
|
+
nickname: string;
|
|
43
|
+
iconUrl: string;
|
|
44
|
+
/** ホスト(mobile / emulator)から渡される、選択済みキャラクターの ID。未選択時は undefined。 */
|
|
45
|
+
characterId?: string;
|
|
46
|
+
/** 席種。 */
|
|
47
|
+
kind: SeatKind;
|
|
48
|
+
}
|
|
49
|
+
/** プレイヤーごとのリアルタイム状態 */
|
|
50
|
+
export interface PlayerVoiceState {
|
|
51
|
+
/** 音声状態。音声通話に未接続の場合は null */
|
|
52
|
+
audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null;
|
|
53
|
+
}
|
|
54
|
+
/** @deprecated 旧形式。新コードでは onPlayersChanged() と PlayerVoiceState を使用 */
|
|
55
|
+
export interface PlayersChangedMessage {
|
|
56
|
+
players: Record<string, PlayerVoiceState>;
|
|
57
|
+
}
|
|
58
|
+
export type Emit = (eventName: string, data?: Record<string, unknown>) => void;
|
|
59
|
+
/** dev harness で観測される 1 件の emit。`data` は `emit(name)` 省略時に空 object になる。 */
|
|
60
|
+
export interface ServerEvent {
|
|
61
|
+
name: string;
|
|
62
|
+
data: Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
export interface SeededRandom {
|
|
65
|
+
float(): number;
|
|
66
|
+
int(max: number): number;
|
|
67
|
+
pick<T>(array: T[]): T;
|
|
68
|
+
shuffle<T>(array: T[]): T[];
|
|
69
|
+
}
|
|
70
|
+
export type ActionHandler<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: {
|
|
71
|
+
tick: number;
|
|
72
|
+
}) => void;
|
|
73
|
+
export type ServerOnlyActionHandlerFn<S> = (state: S, payload: any, playerId: string, emit: Emit, ctx: {
|
|
74
|
+
tick: number;
|
|
75
|
+
}) => Promise<void> | void;
|
|
76
|
+
/** serverOnly() で wrap された handler。`__serverOnly` brand で識別する。 */
|
|
77
|
+
export type ServerOnlyAction<S> = ServerOnlyActionHandlerFn<S> & {
|
|
78
|
+
readonly __serverOnly: true;
|
|
79
|
+
};
|
|
80
|
+
export interface GameLogic<S> {
|
|
81
|
+
/**
|
|
82
|
+
* seats には kind !== 'player' の席 (spectator / admin) も含まれる。
|
|
83
|
+
* ゲームの配役は kind === 'player' (または kind 省略) だけを対象にすること。
|
|
84
|
+
*/
|
|
85
|
+
setup(seats: Seat[], random: SeededRandom): S;
|
|
86
|
+
actions: Record<string, ActionHandler<S> | ServerOnlyAction<S>>;
|
|
87
|
+
update(state: S, ctx: {
|
|
88
|
+
random: SeededRandom;
|
|
89
|
+
tick: number;
|
|
90
|
+
emit: Emit;
|
|
91
|
+
playerInputs: Record<string, Record<string, any>>;
|
|
92
|
+
}): void;
|
|
93
|
+
tickRate?: number;
|
|
94
|
+
}
|
|
95
|
+
export interface GameConfig<S> extends ConnectionCallbacks {
|
|
96
|
+
logic: GameLogic<S>;
|
|
97
|
+
onState: (state: S, myPlayerId: string) => void;
|
|
98
|
+
inputs: (sendAction: (type: string, payload?: any) => void) => void;
|
|
99
|
+
events?: Record<string, (data: Record<string, unknown>) => void>;
|
|
100
|
+
playerCount: number;
|
|
101
|
+
/** Dev harness のデフォルト向き。manifest.json の `orientation` を渡す。 */
|
|
102
|
+
orientation?: 'portrait' | 'landscape';
|
|
103
|
+
/**
|
|
104
|
+
* Dev harness で各 iframe inner viewport 短辺の下限 (CSS px)。
|
|
105
|
+
* default 360。狭い viewport では親 frame に `body { zoom: N }` を当てて
|
|
106
|
+
* iframe 内部の `window.innerWidth/Height` を保証する。
|
|
107
|
+
*/
|
|
108
|
+
devMinIframeShortEdge?: number;
|
|
109
|
+
}
|
|
110
|
+
export type PatchFn = (ops: Operation[]) => void;
|
|
111
|
+
export type SetFn = (path: string, value: unknown) => void;
|
|
112
|
+
export interface SyncConfig<S = any> extends ConnectionCallbacks {
|
|
113
|
+
initialState: (seats: Seat[]) => S;
|
|
114
|
+
onState: (state: S, myPlayerId: string, serverTime: number) => void;
|
|
115
|
+
inputs: (patch: PatchFn, set: SetFn) => void;
|
|
116
|
+
events?: Record<string, (data: Record<string, unknown>) => void>;
|
|
117
|
+
playerCount: number;
|
|
118
|
+
/** Dev harness のデフォルト向き。manifest.json の `orientation` を渡す。 */
|
|
119
|
+
orientation?: 'portrait' | 'landscape';
|
|
120
|
+
/**
|
|
121
|
+
* Dev harness で各 iframe inner viewport 短辺の下限 (CSS px)。
|
|
122
|
+
* default 360。狭い viewport では親 frame に `body { zoom: N }` を当てて
|
|
123
|
+
* iframe 内部の `window.innerWidth/Height` を保証する。
|
|
124
|
+
*/
|
|
125
|
+
devMinIframeShortEdge?: number;
|
|
126
|
+
}
|
|
127
|
+
export interface Operation {
|
|
128
|
+
op: 'replace' | 'add' | 'remove';
|
|
129
|
+
path: string;
|
|
130
|
+
value?: unknown;
|
|
131
|
+
}
|
|
132
|
+
/** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
|
|
133
|
+
export declare const SERVER_TIME: "__SERVER_TIME__";
|
|
134
|
+
/** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
|
|
135
|
+
export declare const DEFAULT_ICON_URLS: readonly ["https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/4d0da24d-bcf2-4f7b-d1a0-f1bb8c747300/original", "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/8c75fccb-41d6-429d-e943-06c728a72a00/original", "https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original"];
|
|
136
|
+
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
|
137
|
+
export interface ConnectionCallbacks {
|
|
138
|
+
/** WebSocket 接続状態が変化した時に呼ばれる */
|
|
139
|
+
onConnectionStateChange?: (state: ConnectionState) => void;
|
|
140
|
+
/** サーバーで patch 適用が失敗した時に呼ばれる (sync モード専用) */
|
|
141
|
+
onPatchFailed?: (reason: string) => void;
|
|
142
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
|
|
2
|
+
export const SERVER_TIME = '__SERVER_TIME__';
|
|
3
|
+
/** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
|
|
4
|
+
export const DEFAULT_ICON_URLS = [
|
|
5
|
+
'https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/4d0da24d-bcf2-4f7b-d1a0-f1bb8c747300/original',
|
|
6
|
+
'https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/8c75fccb-41d6-429d-e943-06c728a72a00/original',
|
|
7
|
+
'https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original',
|
|
8
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uzuhq/code-sdk",
|
|
3
|
+
"version": "0.3.8",
|
|
4
|
+
"description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./dev-globals": {
|
|
12
|
+
"types": "./dist/dev-globals.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://uzu-app.com",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"registry": "https://registry.npmjs.org",
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"happy-dom": "^20.10.6",
|
|
26
|
+
"typescript": "^6.0.0",
|
|
27
|
+
"vitest": "^4.1.10"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"watch": "tsc --watch",
|
|
32
|
+
"test": "vitest run"
|
|
33
|
+
}
|
|
34
|
+
}
|