@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 CHANGED
@@ -144,7 +144,7 @@ sync<GameState>({
144
144
  | キー | 型 | 必須 | 説明 |
145
145
  | -------------- | ------------------------------------------------------------ | ---- | ---------------------------- |
146
146
  | `playerCount` | `number` | Yes | プレイヤー数 |
147
- | `initialState` | `(seats: Seat[]) => S` | Yes | 初期 state を生成 |
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({ seats, ctx }) {
225
+ setup({ players, ctx }) {
226
226
  // 初期 state を生成。ctx.random は SeededRandom、ctx.now はサーバーの実時刻。
227
- // seats には観戦系の席 (kind: 'spectator' | 'admin') も含まれ得るため、
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 を生成。seats には `kind !== 'player'` の席も含まれる |
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
- セッション参加者。ゲームの席を占める `player` のほか、観戦席 (`spectator`) と進行管理席 (`admin`、dev ハーネスのテストプレイ用) がある。
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, seats } = resolveSeatParams(params);
267
- runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, seats);
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, seats } = resolveSeatParams(params);
298
- const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, seats);
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] seats is required. Pass ?seats=[...] in the URL.');
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 seats = raw.map((p) => {
320
- if (!p.kind) {
321
- throw new Error(`[UZU SDK] seats entry ${p.id} is missing kind.`);
322
- }
323
- return {
324
- id: p.id,
325
- nickname: p.name,
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
- const playerCount = raw.filter((p) => p.kind === 'player').length;
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, onState, inputs, events } = config;
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({ seats: players, ctx: { random, now: Date.now() } });
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) => states.push(structuredClone(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, seats: Seat[]): void;
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, seats) {
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: p.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
- seats: JSON.stringify(seats.map(toWire)),
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
- onState: config.onState,
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
+ });
@@ -15,7 +15,6 @@ export function syncLocal(config) {
15
15
  id: `local_${i}`,
16
16
  nickname: `Player ${i + 1}`,
17
17
  iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length],
18
- kind: 'player',
19
18
  });
20
19
  }
21
20
  let state = initialState(players);
package/dist/types.d.ts CHANGED
@@ -31,20 +31,29 @@ export interface BridgeMessage {
31
31
  playerId?: string;
32
32
  }
33
33
  /**
34
- * セッションの席種。
35
- * - player: ゲームの参加者 (キャラクターを担当する)
36
- * - spectator: 観戦者 (state を閲覧するだけで、ゲームの席は占めない)
37
- * - admin: 進行管理席 (観戦 + GM 操作。dev harness のテストプレイ用)
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
- * seats には kind !== 'player' の席 (spectator / admin) も含まれる。
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
- onState: (state: S, myPlayerId: string) => void;
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: (seats: Seat[]) => S;
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>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-sdk",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "UZU PlayScreen SDK - Flutter ↔ JS ゲーム通信ライブラリ",
5
5
  "type": "module",
6
6
  "exports": {