@uzuhq/code-sdk 0.7.4 → 0.7.6
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/README.md +38 -16
- package/dist/index.js +39 -25
- package/dist/roster-params.test.d.ts +1 -0
- package/dist/roster-params.test.js +86 -0
- package/dist/run/local-server-action.js +9 -3
- package/dist/run/local-server-action.test.js +25 -2
- package/dist/run/server-action.d.ts +2 -2
- package/dist/run/server-action.js +12 -4
- package/dist/run/server-action.test.d.ts +1 -0
- package/dist/run/server-action.test.js +105 -0
- package/dist/sync/local.js +0 -1
- package/dist/types.d.ts +23 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -144,7 +144,7 @@ sync<GameState>({
|
|
|
144
144
|
| キー | 型 | 必須 | 説明 |
|
|
145
145
|
| -------------- | ------------------------------------------------------------ | ---- | ---------------------------- |
|
|
146
146
|
| `playerCount` | `number` | Yes | プレイヤー数 |
|
|
147
|
-
| `initialState` | `(
|
|
147
|
+
| `initialState` | `(players: Seat[]) => S` | Yes | 初期 state を生成 |
|
|
148
148
|
| `onState` | `(state: S, myPlayerId: string, serverTime: number) => void` | Yes | state 更新時のコールバック |
|
|
149
149
|
| `inputs` | `(patch: PatchFn, set: SetFn) => void` | Yes | 入力ハンドラ登録 |
|
|
150
150
|
| `events` | `Record<string, (data: Record<string, unknown>) => void>` | No | ゲームイベントハンドラ |
|
|
@@ -222,10 +222,9 @@ run({
|
|
|
222
222
|
import type { GameLogic } from '@uzuhq/code-sdk';
|
|
223
223
|
|
|
224
224
|
const logic: GameLogic<MyState> = {
|
|
225
|
-
setup({
|
|
225
|
+
setup({ players, ctx }) {
|
|
226
226
|
// 初期 state を生成。ctx.random は SeededRandom、ctx.now はサーバーの実時刻。
|
|
227
|
-
//
|
|
228
|
-
// ゲームの席は kind === 'player' に絞る
|
|
227
|
+
// players は配役を受け取る参加者だけで、観測者は含まれない
|
|
229
228
|
return { players: {}, items: [] };
|
|
230
229
|
},
|
|
231
230
|
|
|
@@ -260,13 +259,13 @@ const logic: GameLogic<MyState> = {
|
|
|
260
259
|
};
|
|
261
260
|
```
|
|
262
261
|
|
|
263
|
-
| キー | 型 | 必須 | 説明
|
|
264
|
-
| --------------- | ---------------------------------------- | ---- |
|
|
265
|
-
| `setup` | `(args: SetupArgs) => S` | Yes | 初期 state
|
|
266
|
-
| `actions` | `Record<string, ActionHandler<S>>` | Yes | クライアント先読み + サーバーの 2 回走る。決定的であること
|
|
267
|
-
| `serverActions` | `Record<string, ServerActionHandler<S>>` | No | サーバーでのみ走る。async 可。`ctx` に `tick` / `random` が入る
|
|
268
|
-
| `update` | `(args: UpdateArgs<S>) => void` | Yes | 毎 tick 実行 (`tickRate` が 0 なら呼ばれない)
|
|
269
|
-
| `tickRate` | `number` | No | 秒間 tick 数 (default: 0 = tick なし)
|
|
262
|
+
| キー | 型 | 必須 | 説明 |
|
|
263
|
+
| --------------- | ---------------------------------------- | ---- | --------------------------------------------------------------- |
|
|
264
|
+
| `setup` | `(args: SetupArgs) => S` | Yes | 初期 state を生成。`args.players` は配役を受け取る参加者だけ |
|
|
265
|
+
| `actions` | `Record<string, ActionHandler<S>>` | Yes | クライアント先読み + サーバーの 2 回走る。決定的であること |
|
|
266
|
+
| `serverActions` | `Record<string, ServerActionHandler<S>>` | No | サーバーでのみ走る。async 可。`ctx` に `tick` / `random` が入る |
|
|
267
|
+
| `update` | `(args: UpdateArgs<S>) => void` | Yes | 毎 tick 実行 (`tickRate` が 0 なら呼ばれない) |
|
|
268
|
+
| `tickRate` | `number` | No | 秒間 tick 数 (default: 0 = tick なし) |
|
|
270
269
|
|
|
271
270
|
ハンドラの引数は 1 つのオブジェクトで、使うものだけ書けばよい。
|
|
272
271
|
`state` / `payload` / `playerId` はその呼び出しの事実、`ctx` は実行環境が与えるもの。
|
|
@@ -460,22 +459,45 @@ interface BridgeMessage {
|
|
|
460
459
|
|
|
461
460
|
### Seat
|
|
462
461
|
|
|
463
|
-
|
|
462
|
+
roster に載る席。roster は配役を受け取る参加者だけで構成される。席種別は roster エントリ
|
|
463
|
+
ではなく、自分の `SeatKind` として `onState` に渡る。
|
|
464
464
|
|
|
465
465
|
```ts
|
|
466
|
-
type SeatKind = 'player' | 'spectator' | 'admin';
|
|
467
|
-
|
|
468
466
|
interface Seat {
|
|
469
467
|
id: string;
|
|
470
468
|
nickname: string;
|
|
471
469
|
iconUrl: string;
|
|
472
470
|
/** 選択済みキャラクターの ID。未選択時は undefined */
|
|
473
471
|
characterId?: string;
|
|
474
|
-
/** 席種 */
|
|
475
|
-
kind: SeatKind;
|
|
476
472
|
}
|
|
477
473
|
```
|
|
478
474
|
|
|
475
|
+
### SeatKind
|
|
476
|
+
|
|
477
|
+
自分の席種別。ホストが iframe URL の `?seatKind=` で伝え、SDK が `onState` の第 3 引数
|
|
478
|
+
として渡す。
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
type SeatKind = 'player' | 'spectator' | 'admin';
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
観戦席・進行管理席は roster に載らないまま接続してくる。`setup()` の `players` にも
|
|
485
|
+
現れないので「player か観測者か」は `state.players` の空振りで分かるが、`spectator` と
|
|
486
|
+
`admin` の区別は state から導けない。そこをこの値で分ける。
|
|
487
|
+
|
|
488
|
+
```ts
|
|
489
|
+
onState(state, myPlayerId, mySeatKind) {
|
|
490
|
+
const me = state.players.find((p) => p.playerId === myPlayerId);
|
|
491
|
+
if (me) return playerView(me);
|
|
492
|
+
// roster に居ない = 観測者
|
|
493
|
+
return mySeatKind === 'admin' ? gmView() : spectatorView();
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
自己申告なので表示の分岐にだけ使う。渡るのは自分の席種別だけで、他プレイヤーの席種別は
|
|
498
|
+
サーバー側 handler (`ActionArgs`) にも渡らない。seatId の命名規約 (`admin_0` 等) から
|
|
499
|
+
判定すると、命名が変わった瞬間に静かに壊れるので避けること。
|
|
500
|
+
|
|
479
501
|
### ConnectionState
|
|
480
502
|
|
|
481
503
|
```ts
|
package/dist/index.js
CHANGED
|
@@ -250,9 +250,9 @@ export function run(config) {
|
|
|
250
250
|
const origOnState = config.onState;
|
|
251
251
|
const wrappedConfig = {
|
|
252
252
|
...config,
|
|
253
|
-
onState(state, myPlayerId) {
|
|
253
|
+
onState(state, myPlayerId, mySeatKind) {
|
|
254
254
|
_lastStateSnap = { state, serverTime: 0, myId: myPlayerId };
|
|
255
|
-
origOnState(state, myPlayerId);
|
|
255
|
+
origOnState(state, myPlayerId, mySeatKind);
|
|
256
256
|
notifyDevSnapshot(state);
|
|
257
257
|
},
|
|
258
258
|
};
|
|
@@ -263,8 +263,9 @@ export function run(config) {
|
|
|
263
263
|
}
|
|
264
264
|
else if (_gameEndpoint) {
|
|
265
265
|
// ServerAction モード: GameRoom に接続 (本番 Cloudflare Worker DO / uzu dev の Node ws)
|
|
266
|
-
const { seatId,
|
|
267
|
-
|
|
266
|
+
const { seatId, players } = resolveSeatParams(params);
|
|
267
|
+
const seatKind = resolveSeatKind(params);
|
|
268
|
+
runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
|
|
268
269
|
}
|
|
269
270
|
else {
|
|
270
271
|
throw new Error('[UZU SDK] roomId is set but ?server= is missing. ' +
|
|
@@ -294,8 +295,8 @@ export function sync(config) {
|
|
|
294
295
|
_syncHandle = syncLocal(wrappedConfig);
|
|
295
296
|
}
|
|
296
297
|
else if (_syncEndpoint) {
|
|
297
|
-
const { seatId,
|
|
298
|
-
const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId,
|
|
298
|
+
const { seatId, players } = resolveSeatParams(params);
|
|
299
|
+
const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, players);
|
|
299
300
|
_syncWs = ws;
|
|
300
301
|
// online sync: dev hooks は read-only (setRawState 未提供)
|
|
301
302
|
}
|
|
@@ -306,29 +307,44 @@ export function sync(config) {
|
|
|
306
307
|
attachDevHooksIfNotHosted();
|
|
307
308
|
}
|
|
308
309
|
// ─── Internal ───────────────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* 自分の席種別を URL から取り出す。
|
|
312
|
+
*
|
|
313
|
+
* `?seatKind=` はホスト (harness / emulator) が観測席の iframe に付ける。付いていない
|
|
314
|
+
* ホストからの接続は player とみなす — 本番 (mobile / uzutokyo) は player 席しか開かない。
|
|
315
|
+
*
|
|
316
|
+
* この値は iframe URL 限定で、WebSocket には出さない。play-server は roster に居ない接続を
|
|
317
|
+
* 通すだけで、その席が admin か spectator かを知る必要が無い。
|
|
318
|
+
*/
|
|
319
|
+
function resolveSeatKind(params) {
|
|
320
|
+
const raw = params.get('seatKind');
|
|
321
|
+
return raw === 'admin' || raw === 'spectator' ? raw : 'player';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* 自席の ID と roster を URL から取り出す。
|
|
325
|
+
*
|
|
326
|
+
* roster (`?players=`) に自席が居ないことは異常ではない。観測者はそもそも roster に
|
|
327
|
+
* 載らないまま接続してくる。
|
|
328
|
+
*
|
|
329
|
+
* 移行期: 旧ホスト (デプロイ前の mobile / uzutokyo / emulator) は `?seats=` で送ってくる。
|
|
330
|
+
*/
|
|
309
331
|
function resolveSeatParams(params) {
|
|
310
332
|
const seatId = params.get('seatId');
|
|
311
333
|
if (!seatId) {
|
|
312
334
|
throw new Error('[UZU SDK] seatId is required. Pass ?seatId=xxx in the URL.');
|
|
313
335
|
}
|
|
314
|
-
const json = params.get('seats');
|
|
336
|
+
const json = params.get('players') ?? params.get('seats');
|
|
315
337
|
if (!json) {
|
|
316
|
-
throw new Error('[UZU SDK]
|
|
338
|
+
throw new Error('[UZU SDK] players is required. Pass ?players=[...] in the URL.');
|
|
317
339
|
}
|
|
318
340
|
const raw = JSON.parse(json);
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
iconUrl: p.iconUrl,
|
|
327
|
-
characterId: p.characterId,
|
|
328
|
-
kind: p.kind,
|
|
329
|
-
};
|
|
330
|
-
});
|
|
331
|
-
return { seatId, seats };
|
|
341
|
+
const players = raw.map((p) => ({
|
|
342
|
+
id: p.id,
|
|
343
|
+
nickname: p.name,
|
|
344
|
+
iconUrl: p.iconUrl,
|
|
345
|
+
characterId: p.characterId,
|
|
346
|
+
}));
|
|
347
|
+
return { seatId, players };
|
|
332
348
|
}
|
|
333
349
|
function requireEndpoint(endpoint, name) {
|
|
334
350
|
if (!endpoint) {
|
|
@@ -434,13 +450,11 @@ const ACTION_ITEM_WIDTH = 38;
|
|
|
434
450
|
function calcHudInsets(params) {
|
|
435
451
|
const y = HUD_TOP + UZU_BUTTON_SIZE;
|
|
436
452
|
let actionCount = 2; // デフォルト: 安全側 (マイク + チャット想定)
|
|
437
|
-
const json = params.get('seats');
|
|
453
|
+
const json = params.get('players') ?? params.get('seats');
|
|
438
454
|
if (json) {
|
|
439
455
|
try {
|
|
440
|
-
// spectator / admin 席はゲームの player 数に数えない
|
|
441
456
|
const raw = JSON.parse(json);
|
|
442
|
-
|
|
443
|
-
actionCount = playerCount >= 2 ? 2 : 0;
|
|
457
|
+
actionCount = raw.length >= 2 ? 2 : 0;
|
|
444
458
|
}
|
|
445
459
|
catch {
|
|
446
460
|
// パース失敗時はデフォルト値を使用
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL の roster パラメータ (`?players=` / 旧名 `?seats=`) の解決を固定する unit test。
|
|
3
|
+
*
|
|
4
|
+
* ホスト (mobile / uzutokyo / emulator / dev harness) と SDK の間はデプロイが揃わない。
|
|
5
|
+
* 新旧どちらの名前で渡されても run() が起動し、GameRoom へは新名で送り直すところまでを
|
|
6
|
+
* 1 本で見る。旧名しか読めない SDK / 旧名しか送らないホストが同時に居るのが移行期の前提。
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
const logic = {
|
|
10
|
+
setup: () => ({ moves: 0 }),
|
|
11
|
+
actions: {},
|
|
12
|
+
update: () => { },
|
|
13
|
+
};
|
|
14
|
+
const roster = [
|
|
15
|
+
{ id: 'p1', nickname: 'P1', iconUrl: '' },
|
|
16
|
+
{ id: 'p2', nickname: 'P2', iconUrl: '' },
|
|
17
|
+
];
|
|
18
|
+
const wire = (seats) => JSON.stringify(seats.map((s) => ({ id: s.id, name: s.nickname, iconUrl: s.iconUrl })));
|
|
19
|
+
/** `new WebSocket(url)` を捕まえるスタブ。接続先 URL だけ見る。 */
|
|
20
|
+
const stubWebSocket = () => {
|
|
21
|
+
const urls = [];
|
|
22
|
+
class FakeWebSocket {
|
|
23
|
+
constructor(url) {
|
|
24
|
+
this.url = url;
|
|
25
|
+
this.onopen = null;
|
|
26
|
+
this.onclose = null;
|
|
27
|
+
this.onerror = null;
|
|
28
|
+
this.onmessage = null;
|
|
29
|
+
this.readyState = FakeWebSocket.OPEN;
|
|
30
|
+
urls.push(url);
|
|
31
|
+
}
|
|
32
|
+
send() { }
|
|
33
|
+
close() { }
|
|
34
|
+
}
|
|
35
|
+
FakeWebSocket.OPEN = 1;
|
|
36
|
+
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
37
|
+
return urls;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* 指定のクエリで SDK を読み込み直して `run()` まで通す。
|
|
41
|
+
*
|
|
42
|
+
* `isHosted` は module 評価時に一度だけ決まるので、`FlutterHost` を先に生やしてから
|
|
43
|
+
* import しないと run() が即 return して何も起きない。
|
|
44
|
+
*/
|
|
45
|
+
const runWithQuery = async (query) => {
|
|
46
|
+
vi.resetModules();
|
|
47
|
+
vi.stubGlobal('FlutterHost', { postMessage: () => { } });
|
|
48
|
+
const urls = stubWebSocket();
|
|
49
|
+
window.history.replaceState({}, '', `/?${query}`);
|
|
50
|
+
const sdk = await import('./index.js');
|
|
51
|
+
sdk.run({ logic, playerCount: 2, onState: () => { }, inputs: () => { } });
|
|
52
|
+
return urls;
|
|
53
|
+
};
|
|
54
|
+
const baseQuery = 'roomId=room1&server=ws://localhost:1234&revisionId=rev1&seatId=p1';
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.unstubAllGlobals();
|
|
57
|
+
});
|
|
58
|
+
describe('roster パラメータの解決', () => {
|
|
59
|
+
/** 新ホストは `players=` で送る。 */
|
|
60
|
+
it('players から roster を読む', async () => {
|
|
61
|
+
const urls = await runWithQuery(`${baseQuery}&players=${encodeURIComponent(wire(roster))}`);
|
|
62
|
+
const sent = new URL(urls[0]).searchParams.get('players');
|
|
63
|
+
expect(JSON.parse(sent ?? '[]').map((s) => s.id)).toEqual(['p1', 'p2']);
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* 移行期の互換措置。デプロイ前のホストは旧名でしか送ってこない。読めないと run() の
|
|
67
|
+
* 入口で throw してシナリオが起動しない。
|
|
68
|
+
*/
|
|
69
|
+
it('players が無ければ旧名 seats に落ちる', async () => {
|
|
70
|
+
const urls = await runWithQuery(`${baseQuery}&seats=${encodeURIComponent(wire(roster))}`);
|
|
71
|
+
const sent = new URL(urls[0]).searchParams.get('players');
|
|
72
|
+
expect(JSON.parse(sent ?? '[]').map((s) => s.id)).toEqual(['p1', 'p2']);
|
|
73
|
+
});
|
|
74
|
+
/** 移行期は両方載せたホストが居る。新名を優先し、旧名は fallback にしか使わない。 */
|
|
75
|
+
it('両方あれば players を採る', async () => {
|
|
76
|
+
const old = [{ id: 'old1', nickname: 'Old1', iconUrl: '' }];
|
|
77
|
+
const urls = await runWithQuery(`${baseQuery}&players=${encodeURIComponent(wire(roster))}` +
|
|
78
|
+
`&seats=${encodeURIComponent(wire(old))}`);
|
|
79
|
+
const sent = new URL(urls[0]).searchParams.get('players');
|
|
80
|
+
expect(JSON.parse(sent ?? '[]').map((s) => s.id)).toEqual(['p1', 'p2']);
|
|
81
|
+
});
|
|
82
|
+
/** roster がどちらの名前でも無ければ、黙って空 roster で始めず入口で落とす。 */
|
|
83
|
+
it('どちらも無ければ throw する', async () => {
|
|
84
|
+
await expect(runWithQuery(baseQuery)).rejects.toThrow(/players is required/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -3,7 +3,9 @@ import { SeededRandomImpl } from '../random.js';
|
|
|
3
3
|
import { isServerOnlyAction } from '../server-only.js';
|
|
4
4
|
import { applyJsonMergePatch, applyJsonPatch } from '../dev-state-patch.js';
|
|
5
5
|
export function runLocalServerAction(config) {
|
|
6
|
-
const { logic,
|
|
6
|
+
const { logic, inputs, events } = config;
|
|
7
|
+
// ソロモードは自分ひとりで観測者が存在しないので席種別は常に player。
|
|
8
|
+
const onState = (next, id) => config.onState(next, id, 'player');
|
|
7
9
|
const tickRate = logic.tickRate ?? 0; // DO と同じデフォルト(0=tickなし)
|
|
8
10
|
const seed = Math.floor(Math.random() * 0xffffffff);
|
|
9
11
|
const random = new SeededRandomImpl(seed);
|
|
@@ -11,7 +13,6 @@ export function runLocalServerAction(config) {
|
|
|
11
13
|
id: `local_${i}`,
|
|
12
14
|
nickname: `Player ${i + 1}`,
|
|
13
15
|
iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length],
|
|
14
|
-
kind: 'player',
|
|
15
16
|
}));
|
|
16
17
|
const myId = players[0].id;
|
|
17
18
|
// イベント収集→一括配信(DO と同じパターン)
|
|
@@ -98,8 +99,13 @@ export function runLocalServerAction(config) {
|
|
|
98
99
|
events?.[e.name]?.handler(e.data);
|
|
99
100
|
}
|
|
100
101
|
};
|
|
102
|
+
// 移行期: publish 済みの logic.js は `setup({ seats })` で destructure したまま
|
|
103
|
+
// 固まっている。`players` へ寄せただけだと `seats === undefined` を受け取って
|
|
104
|
+
// throw するので、同じ配列を旧名でも渡す。`SetupArgs` に `seats` を宣言しないのは、
|
|
105
|
+
// 新規シナリオに旧名を選ばせないため。全 revision の再 publish 後に落とす。
|
|
106
|
+
const setupArgs = { players, seats: players, ctx: { random, now: Date.now() } };
|
|
101
107
|
// setRawState で全置換できるよう let。closures は名前参照なので最新束縛を読む。
|
|
102
|
-
let state = logic.setup(
|
|
108
|
+
let state = logic.setup(setupArgs);
|
|
103
109
|
let tick = 0;
|
|
104
110
|
const playerInputs = {};
|
|
105
111
|
// Action 処理。`actions` は同期実行で `sendAction()` 直後の同期 onState を保証する
|
|
@@ -17,11 +17,15 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
17
17
|
const run = (logic) => {
|
|
18
18
|
const fired = [];
|
|
19
19
|
const states = [];
|
|
20
|
+
const seatKinds = [];
|
|
20
21
|
let send = () => { };
|
|
21
22
|
const config = {
|
|
22
23
|
logic,
|
|
23
24
|
playerCount: 1,
|
|
24
|
-
onState: (s) =>
|
|
25
|
+
onState: (s, _myPlayerId, mySeatKind) => {
|
|
26
|
+
states.push(structuredClone(s));
|
|
27
|
+
seatKinds.push(mySeatKind);
|
|
28
|
+
},
|
|
25
29
|
inputs: (sendAction) => {
|
|
26
30
|
send = sendAction;
|
|
27
31
|
},
|
|
@@ -41,7 +45,7 @@ const run = (logic) => {
|
|
|
41
45
|
},
|
|
42
46
|
};
|
|
43
47
|
runLocalServerAction(config);
|
|
44
|
-
return { send: (t, p) => send(t, p), fired, states };
|
|
48
|
+
return { send: (t, p) => send(t, p), fired, states, seatKinds };
|
|
45
49
|
};
|
|
46
50
|
const baseLogic = (overrides = {}) => ({
|
|
47
51
|
setup: () => ({ moves: 0, charged: 0, rolled: -1 }),
|
|
@@ -216,4 +220,23 @@ describe('runLocalServerAction', () => {
|
|
|
216
220
|
expect(h.fired).toEqual(['charged']);
|
|
217
221
|
});
|
|
218
222
|
});
|
|
223
|
+
/**
|
|
224
|
+
* `onState` の第 3 引数は自分の席種別。roster に自分が居ないとき (観測者) に
|
|
225
|
+
* GM ビューと観戦ビューを出し分けるためのもので、他プレイヤーの席種別は渡らない。
|
|
226
|
+
*/
|
|
227
|
+
describe('自分の席種別', () => {
|
|
228
|
+
/** ソロモードは自分ひとりで観測者が存在しないので、常に 'player' が渡る。 */
|
|
229
|
+
it('ソロモードでは常に player が渡る', () => {
|
|
230
|
+
const h = run(baseLogic({
|
|
231
|
+
actions: {
|
|
232
|
+
move: ({ state }) => {
|
|
233
|
+
state.moves += 1;
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}));
|
|
237
|
+
h.send('move');
|
|
238
|
+
expect(h.seatKinds.length).toBeGreaterThan(1);
|
|
239
|
+
expect(new Set(h.seatKinds)).toEqual(new Set(['player']));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
219
242
|
});
|
|
@@ -12,5 +12,5 @@
|
|
|
12
12
|
* `optimistic-action-client.ts` に集約。本ファイルは WebSocket 固有の責務
|
|
13
13
|
* (接続管理 / メッセージ振り分け / delta seq の連続性チェック / フル state 再要求) のみ持つ。
|
|
14
14
|
*/
|
|
15
|
-
import type { GameConfig, Seat } from '../types.js';
|
|
16
|
-
export declare function runOnlineServerAction<S>(config: GameConfig<S>, gameEndpoint: string, roomId: string, seatId: string,
|
|
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;
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import { ReconnectableWebSocket } from '../reconnectable-ws.js';
|
|
2
2
|
import { createOptimisticActionClient } from './optimistic-action-client.js';
|
|
3
|
-
export function runOnlineServerAction(config, gameEndpoint, roomId, seatId,
|
|
3
|
+
export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, seatKind) {
|
|
4
|
+
// `kind` は移行期の互換措置。旧 SDK / 旧 play-server が読む wire を変えないために
|
|
5
|
+
// 残す。roster は player だけになったので値は常に 'player'。
|
|
4
6
|
const toWire = (p) => ({
|
|
5
7
|
id: p.id,
|
|
6
8
|
name: p.nickname,
|
|
7
9
|
iconUrl: p.iconUrl,
|
|
8
10
|
characterId: p.characterId,
|
|
9
|
-
kind:
|
|
11
|
+
kind: 'player',
|
|
10
12
|
});
|
|
13
|
+
const roster = JSON.stringify(players.map(toWire));
|
|
11
14
|
const query = new URLSearchParams({
|
|
12
15
|
seatId,
|
|
13
16
|
nickname: 'Player',
|
|
14
|
-
|
|
17
|
+
players: roster,
|
|
18
|
+
// 移行期: play-server / dev-server が `players=` を読めるようになる前の revision へ
|
|
19
|
+
// 繋ぐ経路が残る (JIT 再デプロイ前の焼き込み済みテンプレなど)。旧名も併せて送る。
|
|
20
|
+
seats: roster,
|
|
15
21
|
});
|
|
16
22
|
const wsUrl = `${gameEndpoint}/${roomId}?${query}`;
|
|
17
23
|
console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
|
|
@@ -44,7 +50,9 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
|
|
|
44
50
|
const client = createOptimisticActionClient({
|
|
45
51
|
logic: config.logic,
|
|
46
52
|
playerId: seatId,
|
|
47
|
-
|
|
53
|
+
// seatKind は接続ごとに固定なのでここで束ねる。楽観更新クライアント側は
|
|
54
|
+
// 席種別を一切見ない (state の再適用にしか関心が無い)。
|
|
55
|
+
onState: (state, playerId) => config.onState(state, playerId, seatKind),
|
|
48
56
|
events: config.events,
|
|
49
57
|
sendAction: ({ action, payload, seq }) => {
|
|
50
58
|
console.log(`[SDK ServerAction] ➡ send __action action=${action} seq=${seq}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
});
|
package/dist/sync/local.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -31,20 +31,29 @@ export interface BridgeMessage {
|
|
|
31
31
|
playerId?: string;
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
34
|
+
* 自分の席種別。ホストが iframe URL の `?seatKind=` で伝える。
|
|
35
|
+
*
|
|
36
|
+
* roster に載るのは `player` だけ。観測者 (`spectator` / `admin`) は roster 外の接続として
|
|
37
|
+
* 開くので、「player か観測者か」は `state.players` の空振りで分かる。一方 **`spectator` と
|
|
38
|
+
* `admin` の区別は state から導けない**ため、この値で分ける。seatId の命名規約
|
|
39
|
+
* (`admin_0` 等) をシナリオに見せると、規約が変わった瞬間に静かに壊れる。
|
|
40
|
+
*
|
|
41
|
+
* 自己申告なので権限の根拠にはならない。表示の分岐にだけ使うこと。
|
|
38
42
|
*/
|
|
39
43
|
export type SeatKind = 'player' | 'spectator' | 'admin';
|
|
44
|
+
/**
|
|
45
|
+
* roster に載る席。
|
|
46
|
+
*
|
|
47
|
+
* roster は配役を受け取る参加者だけで構成される。観測者 (GM 席・観戦席) は roster に
|
|
48
|
+
* 載らないまま接続してくるので、シナリオは「roster に居ない = 観測者」で判別する。
|
|
49
|
+
* 席種別は roster エントリではなく自分の `SeatKind` として渡る。
|
|
50
|
+
*/
|
|
40
51
|
export interface Seat {
|
|
41
52
|
id: string;
|
|
42
53
|
nickname: string;
|
|
43
54
|
iconUrl: string;
|
|
44
55
|
/** ホスト(mobile / emulator)から渡される、選択済みキャラクターの ID。未選択時は undefined。 */
|
|
45
56
|
characterId?: string;
|
|
46
|
-
/** 席種。 */
|
|
47
|
-
kind: SeatKind;
|
|
48
57
|
}
|
|
49
58
|
/** プレイヤーごとのリアルタイム状態 */
|
|
50
59
|
export interface PlayerVoiceState {
|
|
@@ -218,11 +227,8 @@ export interface SetupContext {
|
|
|
218
227
|
}
|
|
219
228
|
/** `setup()` の引数。 */
|
|
220
229
|
export interface SetupArgs {
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
* ゲームの配役は kind === 'player' (または kind 省略) だけを対象にすること。
|
|
224
|
-
*/
|
|
225
|
-
seats: Seat[];
|
|
230
|
+
/** 配役を受け取る参加者。観測者は含まれない。 */
|
|
231
|
+
players: Seat[];
|
|
226
232
|
ctx: SetupContext;
|
|
227
233
|
}
|
|
228
234
|
/**
|
|
@@ -279,7 +285,11 @@ export interface GameLogic<S, A extends ActionMap<S> = ActionMap<S>, SA extends
|
|
|
279
285
|
}
|
|
280
286
|
export interface GameConfig<S, A extends ActionMap<S> = ActionMap<S>, SA extends ServerActionMap<S> = ServerActionMap<S>> extends ConnectionCallbacks {
|
|
281
287
|
logic: GameLogic<S, A, SA>;
|
|
282
|
-
|
|
288
|
+
/**
|
|
289
|
+
* `mySeatKind` は自分の席種別。roster に自分が居ない (= 観測者) ときに、GM ビューと
|
|
290
|
+
* 観戦ビューを出し分けるために使う。他プレイヤーの席種別は渡らない。
|
|
291
|
+
*/
|
|
292
|
+
onState: (state: S, myPlayerId: string, mySeatKind: SeatKind) => void;
|
|
283
293
|
inputs: (sendAction: SendAction<A & SA>) => void;
|
|
284
294
|
/**
|
|
285
295
|
* `emit(name, data)` の購読。 キーごとに `predict` の宣言が必須。
|
|
@@ -303,7 +313,7 @@ export interface GameConfig<S, A extends ActionMap<S> = ActionMap<S>, SA extends
|
|
|
303
313
|
export type PatchFn = (ops: Operation[]) => void;
|
|
304
314
|
export type SetFn = (path: string, value: unknown) => void;
|
|
305
315
|
export interface SyncConfig<S = any> extends ConnectionCallbacks {
|
|
306
|
-
initialState: (
|
|
316
|
+
initialState: (players: Seat[]) => S;
|
|
307
317
|
onState: (state: S, myPlayerId: string, serverTime: number) => void;
|
|
308
318
|
inputs: (patch: PatchFn, set: SetFn) => void;
|
|
309
319
|
events?: Record<string, (data: Record<string, unknown>) => void>;
|