@uzuhq/code-sdk 0.7.3 → 0.7.5
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 +34 -22
- 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 +8 -4
- package/dist/run/server-action.test.d.ts +1 -0
- package/dist/run/server-action.test.js +96 -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,6 +307,25 @@ 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 (`?seats=`) に自席が居ないことは異常ではない。観測者はそもそも roster に
|
|
327
|
+
* 載らないまま接続してくる。
|
|
328
|
+
*/
|
|
309
329
|
function resolveSeatParams(params) {
|
|
310
330
|
const seatId = params.get('seatId');
|
|
311
331
|
if (!seatId) {
|
|
@@ -316,19 +336,13 @@ function resolveSeatParams(params) {
|
|
|
316
336
|
throw new Error('[UZU SDK] seats is required. Pass ?seats=[...] in the URL.');
|
|
317
337
|
}
|
|
318
338
|
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 };
|
|
339
|
+
const players = raw.map((p) => ({
|
|
340
|
+
id: p.id,
|
|
341
|
+
nickname: p.name,
|
|
342
|
+
iconUrl: p.iconUrl,
|
|
343
|
+
characterId: p.characterId,
|
|
344
|
+
}));
|
|
345
|
+
return { seatId, players };
|
|
332
346
|
}
|
|
333
347
|
function requireEndpoint(endpoint, name) {
|
|
334
348
|
if (!endpoint) {
|
|
@@ -437,10 +451,8 @@ function calcHudInsets(params) {
|
|
|
437
451
|
const json = params.get('seats');
|
|
438
452
|
if (json) {
|
|
439
453
|
try {
|
|
440
|
-
// spectator / admin 席はゲームの player 数に数えない
|
|
441
454
|
const raw = JSON.parse(json);
|
|
442
|
-
|
|
443
|
-
actionCount = playerCount >= 2 ? 2 : 0;
|
|
455
|
+
actionCount = raw.length >= 2 ? 2 : 0;
|
|
444
456
|
}
|
|
445
457
|
catch {
|
|
446
458
|
// パース失敗時はデフォルト値を使用
|
|
@@ -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,19 @@
|
|
|
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
|
});
|
|
11
13
|
const query = new URLSearchParams({
|
|
12
14
|
seatId,
|
|
13
15
|
nickname: 'Player',
|
|
14
|
-
seats: JSON.stringify(
|
|
16
|
+
seats: JSON.stringify(players.map(toWire)),
|
|
15
17
|
});
|
|
16
18
|
const wsUrl = `${gameEndpoint}/${roomId}?${query}`;
|
|
17
19
|
console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
|
|
@@ -44,7 +46,9 @@ export function runOnlineServerAction(config, gameEndpoint, roomId, seatId, seat
|
|
|
44
46
|
const client = createOptimisticActionClient({
|
|
45
47
|
logic: config.logic,
|
|
46
48
|
playerId: seatId,
|
|
47
|
-
|
|
49
|
+
// seatKind は接続ごとに固定なのでここで束ねる。楽観更新クライアント側は
|
|
50
|
+
// 席種別を一切見ない (state の再適用にしか関心が無い)。
|
|
51
|
+
onState: (state, playerId) => config.onState(state, playerId, seatKind),
|
|
48
52
|
events: config.events,
|
|
49
53
|
sendAction: ({ action, payload, seq }) => {
|
|
50
54
|
console.log(`[SDK ServerAction] ➡ send __action action=${action} seq=${seq}`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
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 (`?seats=`) は 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('seats') ?? '[]');
|
|
82
|
+
expect(raw.map((s) => s.id)).toEqual(['p1', 'p2']);
|
|
83
|
+
});
|
|
84
|
+
/** シナリオが GM ビューと観戦ビューを分けられるよう、自分の席種別だけを渡す。 */
|
|
85
|
+
it('onState の第 3 引数に自分の seatKind を渡す', () => {
|
|
86
|
+
const { sockets, onState } = connect('admin', 'admin_0');
|
|
87
|
+
sockets[0].push({ type: '__game_start', state: { moves: 0 }, seq: 0 });
|
|
88
|
+
expect(onState).toHaveBeenCalledWith({ moves: 0 }, 'admin_0', 'admin');
|
|
89
|
+
});
|
|
90
|
+
/** player 席も同じ経路で自分の席種別を受け取る。 */
|
|
91
|
+
it('player 席には player が渡る', () => {
|
|
92
|
+
const { sockets, onState } = connect('player');
|
|
93
|
+
sockets[0].push({ type: '__game_start', state: { moves: 0 }, seq: 0 });
|
|
94
|
+
expect(onState).toHaveBeenCalledWith({ moves: 0 }, 'p1', 'player');
|
|
95
|
+
});
|
|
96
|
+
});
|
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>;
|