@uzuhq/code-cli 0.5.4 → 0.5.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.
@@ -14,6 +14,33 @@
14
14
  import { randomUUID } from 'crypto';
15
15
  import { compare, applyJsonMergePatch, applyJsonPatch } from './json-patch.js';
16
16
  import { SeededRandomImpl } from './random.js';
17
+ /**
18
+ * `?seats=` を player だけの roster に落とす。 壊れた申告は null。
19
+ *
20
+ * 観測者は roster に載らない。 旧ホストは観測席も載せてくるので、 配役に混ざらないよう
21
+ * ここで落とす。
22
+ */
23
+ function parseRoster(seatsParam) {
24
+ if (!seatsParam)
25
+ return null;
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(seatsParam);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (!Array.isArray(raw))
34
+ return null;
35
+ return raw
36
+ .filter((p) => (p.kind ?? 'player') === 'player')
37
+ .map((p) => ({
38
+ id: p.id,
39
+ nickname: p.name ?? 'Guest',
40
+ iconUrl: p.iconUrl ?? '',
41
+ characterId: p.characterId,
42
+ }));
43
+ }
17
44
  /**
18
45
  * serverOnly() で wrap された handler かを判定する (SDK の isServerOnlyAction と同じ brand)。
19
46
  * dev-server は SDK を import できないため実装を持つ。
@@ -36,14 +63,14 @@ export class GameRoom {
36
63
  tickPaused = false;
37
64
  playerInputs = {};
38
65
  /**
39
- * roster (kind 付き)spectator / admin 席も含む logic.setup にはこれを渡す。
66
+ * roster。 配役を受け取る player 席のみで、 観測席 (GM 席・観戦席) は載らない。
40
67
  * dev harness では manifest から組んだ roster を constructor で注入する
41
68
  * (server 権威)。 その場合、 接続クエリの roster 申告は一切採用しないので、
42
69
  * 旧世代 harness page の残タブが reconnect しても roster を汚染できない。
43
70
  * 本番 DO は backend 由来の roster を全 client が同一申告するため接続時
44
71
  * 登録で成立している — 権威が platform 側にある点は同じ。
45
72
  */
46
- seats = [];
73
+ players = [];
47
74
  seq = 0;
48
75
  prevBroadcastState = null;
49
76
  static SNAPSHOT_INTERVAL = 20;
@@ -51,14 +78,12 @@ export class GameRoom {
51
78
  attachments = new WeakMap();
52
79
  snapshotSubscribers = new Set();
53
80
  eventSubscribers = new Set();
54
- constructor(logic, seats) {
81
+ constructor(logic, players) {
55
82
  this.logic = logic;
56
83
  this.tickRate = logic.tickRate ?? 0;
57
- if (seats && seats.length > 0) {
58
- this.seats = seats;
59
- console.log(`[GameRoom] 📋 Seats (server-authoritative): ${seats
60
- .map((p) => `${p.id}(${p.kind ?? 'player'})`)
61
- .join(', ')}`);
84
+ if (players && players.length > 0) {
85
+ this.players = players;
86
+ console.log(`[GameRoom] 📋 Roster (server-authoritative): ${players.map((p) => p.id).join(', ')}`);
62
87
  }
63
88
  }
64
89
  // ─── Broadcast ─────────────────────────────────────────
@@ -153,22 +178,32 @@ export class GameRoom {
153
178
  });
154
179
  }
155
180
  // ─── Game Lifecycle ────────────────────────────────────
181
+ /**
182
+ * 移行期: 手元のシナリオが `setup({ seats })` のままでも `uzu dev` を動かせるよう、
183
+ * 同じ配列を旧名でも渡す。 本番 (play-server) が publish 済み logic.js のために
184
+ * 同じことをしているのと揃えている。 `SetupArgs` に `seats` を宣言しないのは、
185
+ * 新規シナリオに旧名を選ばせないため。
186
+ */
187
+ setupArgs(random) {
188
+ return {
189
+ players: this.players,
190
+ seats: this.players,
191
+ ctx: { random, now: Date.now() },
192
+ };
193
+ }
156
194
  maybeStartGame() {
157
195
  if (this.gameState !== null)
158
196
  return;
159
- if (this.seats.length === 0)
197
+ if (this.players.length === 0)
160
198
  return;
161
199
  this.seed = Date.now() & 0xffffffff;
162
200
  this.random = new SeededRandomImpl(this.seed);
163
- this.gameState = this.logic.setup({
164
- seats: this.seats,
165
- ctx: { random: this.random, now: Date.now() },
166
- });
201
+ this.gameState = this.logic.setup(this.setupArgs(this.random));
167
202
  this.stateInitialized = true;
168
203
  this.tickCount = 0;
169
204
  this.seq = 0;
170
205
  this.prevBroadcastState = structuredClone(this.gameState);
171
- console.log(`[GameRoom] ✅ Game started with ${this.seats.filter((p) => p.kind === 'player').length} players (${this.seats.length} seats)`);
206
+ console.log(`[GameRoom] ✅ Game started with ${this.players.length} players`);
172
207
  this.broadcastAll({
173
208
  type: '__game_start',
174
209
  state: this.gameState,
@@ -237,30 +272,16 @@ export class GameRoom {
237
272
  }
238
273
  const nickname = url.searchParams.get('nickname') ?? 'Guest';
239
274
  const connectionId = randomUUID();
240
- if (this.seats.length === 0) {
275
+ // roster に自席が居なくても弾かない。 観測席 (GM 席・観戦席) は roster に載らない
276
+ // まま接続してくる。 シナリオ側は state.players の空振りで観測者ビューを返す。
277
+ if (this.players.length === 0) {
241
278
  // constructor 注入 (server 権威) が無い場合のみ、 接続クエリの申告 roster に
242
279
  // fallback する (本番 DO と同じ経路)。 dev harness では常に注入済みなので
243
280
  // ここは通らない。
244
- const rosterParam = url.searchParams.get('seats');
245
- if (rosterParam) {
246
- try {
247
- const raw = JSON.parse(rosterParam);
248
- if (Array.isArray(raw)) {
249
- this.seats = raw.map((p) => ({
250
- id: p.id,
251
- nickname: p.name ?? 'Guest',
252
- iconUrl: p.iconUrl ?? '',
253
- characterId: p.characterId,
254
- kind: p.kind,
255
- }));
256
- console.log(`[GameRoom] 📋 Seats registered: ${this.seats
257
- .map((p) => `${p.id}(${p.kind})`)
258
- .join(', ')}`);
259
- }
260
- }
261
- catch {
262
- /* ignore */
263
- }
281
+ const declared = parseRoster(url.searchParams.get('seats'));
282
+ if (declared && declared.length > 0) {
283
+ this.players = declared;
284
+ console.log(`[GameRoom] 📋 Roster pinned: ${this.players.map((p) => p.id).join(', ')}`);
264
285
  }
265
286
  }
266
287
  console.log(`[GameRoom] 🔗 New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`);
@@ -576,10 +597,7 @@ export class GameRoom {
576
597
  this.seed = opts.seed;
577
598
  }
578
599
  this.random = new SeededRandomImpl(this.seed);
579
- this.gameState = this.logic.setup({
580
- seats: this.seats,
581
- ctx: { random: this.random, now: Date.now() },
582
- });
600
+ this.gameState = this.logic.setup(this.setupArgs(this.random));
583
601
  this.tickCount = 0;
584
602
  // reset は新規ゲーム = 実行可能状態を意味するので pause も解除する。
585
603
  this.tickPaused = false;
@@ -55,7 +55,7 @@ export function startHarnessServer(opts) {
55
55
  const key = `${revisionId}/${roomId}`;
56
56
  let room = gameRooms.get(key);
57
57
  if (!room) {
58
- room = new GameRoom(opts.logic, opts.meta.seats);
58
+ room = new GameRoom(opts.logic, opts.meta.players);
59
59
  gameRooms.set(key, room);
60
60
  }
61
61
  room.handleConnection(ws, url);
package/dist/dev.js CHANGED
@@ -77,27 +77,24 @@ export async function runDevCommand() {
77
77
  const roomKey = 'devroom';
78
78
  const harnessPort = await findFreePort();
79
79
  const lanHosts = currentLanHosts();
80
- // 完全な roster をここで一度だけ組む。 gridiframe URL・GameRoom 注入の単一の源。
81
- const seats = [
82
- ...Array.from({ length: playerCount }, (_, i) => ({
83
- id: `dev_${i}`,
84
- nickname: manifest.characters?.[i]?.name ?? `Player ${i + 1}`,
85
- iconUrl: manifest.characters?.[i]?.icon ?? '',
86
- characterId: manifest.characters?.[i]?.id,
87
- kind: 'player',
88
- })),
89
- ...(manifest.spectator
90
- ? [{ id: 'spec_0', nickname: '観戦', iconUrl: '', kind: 'spectator' }]
91
- : []),
92
- ...(manifest.admin
93
- ? [{ id: 'admin_0', nickname: 'Admin', iconUrl: '', kind: 'admin' }]
94
- : []),
95
- ];
80
+ // roster をここで一度だけ組む。 grid の player セル・iframe URL・GameRoom 注入の単一の源。
81
+ const players = Array.from({ length: playerCount }, (_, i) => ({
82
+ id: `dev_${i}`,
83
+ nickname: manifest.characters?.[i]?.name ?? `Player ${i + 1}`,
84
+ iconUrl: manifest.characters?.[i]?.icon ?? '',
85
+ characterId: manifest.characters?.[i]?.id,
86
+ }));
87
+ // 観測席は roster に載せない。 harness が pane を出すだけで、 scenario からは
88
+ // state.players に居ない席として見える。
89
+ const admin = manifest.admin ?? false;
90
+ const spectator = manifest.spectator ?? false;
96
91
  const meta = {
97
92
  scenarioUrl,
98
93
  playerCount,
99
94
  orientation,
100
- seats,
95
+ players,
96
+ admin,
97
+ spectator,
101
98
  roomKey,
102
99
  serverBaseUrl: `ws://localhost:${harnessPort}`,
103
100
  revisionId: 'dev',
@@ -117,9 +114,9 @@ export async function runDevCommand() {
117
114
  console.log(` 🎮 UZU dev harness: ${harnessUrl}`);
118
115
  console.log(` scenario: ${scenarioUrl}`);
119
116
  console.log(` players: ${playerCount} orientation: ${orientation} roomKey: ${roomKey}`);
120
- const extraSeats = seats.filter((seat) => seat.kind !== 'player');
121
- if (extraSeats.length > 0) {
122
- console.log(` extra seats: ${extraSeats.map((s) => `${s.kind} (${s.id})`).join(', ')}`);
117
+ const observerIds = [...(spectator ? ['spec_0'] : []), ...(admin ? ['admin_0'] : [])];
118
+ if (observerIds.length > 0) {
119
+ console.log(` observer seats (roster 外): ${observerIds.join(', ')}`);
123
120
  }
124
121
  if (lanHosts.length) {
125
122
  console.log('');
@@ -5,7 +5,7 @@
5
5
  * `window.__uzu_dev` に expose する。 esbuild で IIFE bundle 済み文字列として
6
6
  * dev-server が `/_uzu_harness.js` から serve する。
7
7
  */
8
- import { mountIframeGrid, mountSinglePlayer } from './mount.js';
8
+ import { mountIframeGrid, mountSinglePlayer, paneCount } from './mount.js';
9
9
  import { attachAdminClient } from './admin-client.js';
10
10
  /**
11
11
  * meta の URL は CLI 視点 (localhost) で書かれている。 スマホ実機など
@@ -40,10 +40,10 @@ async function main() {
40
40
  // admin channel は grid (親frame) だけが張るので、 単体表示では dev メニューを出さない。
41
41
  const playerParam = new URLSearchParams(location.search).get('player');
42
42
  const playerIndex = playerParam == null ? null : Number(playerParam);
43
- const seatCount = meta.seats.length;
43
+ const panes = paneCount(meta);
44
44
  if (playerIndex != null && Number.isInteger(playerIndex)) {
45
- if (playerIndex < 0 || playerIndex >= seatCount) {
46
- document.body.textContent = `player は 0〜${seatCount - 1} で指定してください`;
45
+ if (playerIndex < 0 || playerIndex >= panes) {
46
+ document.body.textContent = `player は 0〜${panes - 1} で指定してください`;
47
47
  return;
48
48
  }
49
49
  mountSinglePlayer({
@@ -55,7 +55,9 @@ async function main() {
55
55
  serverBaseUrl: meta.serverBaseUrl,
56
56
  revisionId: meta.revisionId,
57
57
  roomKey: meta.roomKey,
58
- seats: meta.seats,
58
+ players: meta.players,
59
+ admin: meta.admin,
60
+ spectator: meta.spectator,
59
61
  }, playerIndex);
60
62
  return;
61
63
  }
@@ -79,7 +81,9 @@ async function main() {
79
81
  serverBaseUrl: meta.serverBaseUrl,
80
82
  revisionId: meta.revisionId,
81
83
  roomKey: meta.roomKey,
82
- seats: meta.seats,
84
+ players: meta.players,
85
+ admin: meta.admin,
86
+ spectator: meta.spectator,
83
87
  // 実機リンク用 host は rewrite 前の meta から取る (localhost 視点の LAN/mDNS 一覧)。
84
88
  lanHosts: rawMeta.lanHosts,
85
89
  // 各 cell の UZU ボタンに配線する dev メニュー。
@@ -161,9 +161,9 @@ export function mountSinglePlayer(opts, playerIndex) {
161
161
  splash.textContent = 'waiting firstFrameReady…';
162
162
  opts.target.appendChild(iframe);
163
163
  opts.target.appendChild(splash);
164
- // 実機でも本番アプリと同じ overlay chrome を出す (ハリボテ)。 admin 席は端末を
164
+ // 実機でも本番アプリと同じ overlay chrome を出す (ハリボテ)。 GM 席は端末を
165
165
  // 模す必要が無いので載せない。 UZU ボタンだけは「他プレイヤー画面へ切替」に配線する。
166
- if (seatAt(opts, playerIndex).kind !== 'admin') {
166
+ if (paneAt(opts, playerIndex).seatKind !== 'admin') {
167
167
  const chrome = createCellChrome(opts, playerIndex, true, undefined, () => {
168
168
  const uzuBtn = chrome.querySelector('button');
169
169
  if (uzuBtn)
@@ -273,39 +273,73 @@ function createLifecycleStatusRow() {
273
273
  row.appendChild(predictionWarn);
274
274
  return { el: row, dots, predictionWarn };
275
275
  }
276
- function seatAt(opts, index) {
277
- const seat = opts.seats[index];
278
- if (!seat)
279
- throw new Error(`seat index out of range: ${index}`);
280
- return seat;
276
+ /**
277
+ * roster に載らない観測席の pane。 席の中身は固定なので manifest の宣言から起こす。
278
+ * grid の中だけの存在で、 GameRoom にも iframe の `?seats=` にも渡らない。
279
+ */
280
+ function observerPanes(opts) {
281
+ const pane = (id, nickname, seatKind) => ({
282
+ id,
283
+ nickname,
284
+ seatKind,
285
+ playerNo: null,
286
+ });
287
+ return [
288
+ ...(opts.spectator ? [pane('spec_0', '観戦', 'spectator')] : []),
289
+ ...(opts.admin ? [pane('admin_0', 'Admin', 'admin')] : []),
290
+ ];
291
+ }
292
+ export function paneCount(opts) {
293
+ return opts.players.length + observerPanes(opts).length;
281
294
  }
282
- function isPlayerSeat(seat) {
283
- return seat.kind === 'player';
295
+ function paneAt(opts, index) {
296
+ const player = opts.players[index];
297
+ if (player) {
298
+ return {
299
+ id: player.id,
300
+ nickname: player.nickname,
301
+ seatKind: 'player',
302
+ playerNo: index + 1,
303
+ };
304
+ }
305
+ const observer = observerPanes(opts)[index - opts.players.length];
306
+ if (!observer)
307
+ throw new Error(`pane index out of range: ${index}`);
308
+ return observer;
284
309
  }
285
- /** SDK が読む wire 形式 (nickname → name)。 */
310
+ /**
311
+ * SDK が読む wire 形式 (nickname → name)。
312
+ *
313
+ * `kind` は移行期の互換措置。 旧 SDK (0.7.3 等) は `kind` の無い席を受け取ると
314
+ * throw する。 roster は player だけになったので値は常に 'player'。
315
+ */
286
316
  function toWireSeat(seat) {
287
317
  return {
288
318
  id: seat.id,
289
319
  name: seat.nickname,
290
320
  iconUrl: seat.iconUrl,
291
321
  characterId: seat.characterId,
292
- kind: seat.kind,
322
+ kind: 'player',
293
323
  };
294
324
  }
295
325
  function buildIframeUrl(opts, playerIndex) {
296
326
  const url = new URL(opts.scenarioUrl);
297
- const seat = seatAt(opts, playerIndex);
327
+ const pane = paneAt(opts, playerIndex);
298
328
  url.searchParams.set('server', opts.serverBaseUrl);
299
329
  url.searchParams.set('roomId', opts.roomKey);
300
- url.searchParams.set('seatId', seat.id);
301
- url.searchParams.set('seats', JSON.stringify(opts.seats.map(toWireSeat)));
330
+ url.searchParams.set('seatId', pane.id);
331
+ // roster は player 席だけ。 観測席の iframe も同じ roster を送る (自分は載らない)
332
+ url.searchParams.set('seats', JSON.stringify(opts.players.map(toWireSeat)));
333
+ // 自分の席種別。 観測席で GM ビューと観戦ビューを出し分けるのに要る (state からは
334
+ // 導けない)。 iframe URL 限定で WebSocket には出さない。
335
+ url.searchParams.set('seatKind', pane.seatKind);
302
336
  url.searchParams.set('revisionId', opts.revisionId);
303
337
  // SDK は解釈しないが、 scenario 側で「parent は harness page (audio consumer 無し) か」を
304
338
  // 判定するマーカーとして残す (tenbo の __uzuAudio bridge スキップ判定など)。
305
339
  url.searchParams.set('__dev_player', String(playerIndex));
306
340
  url.searchParams.set('__dev_room', opts.roomKey);
307
- if (seat.kind === 'admin') {
308
- // admin 席は本番 overlay chrome を載せないため避け領域も不要。
341
+ if (pane.seatKind === 'admin') {
342
+ // GM 席は本番 overlay chrome を載せないため避け領域も不要。
309
343
  url.searchParams.set('uzuHudInsetX', '0');
310
344
  url.searchParams.set('uzuHudInsetY', '0');
311
345
  return url.toString();
@@ -403,11 +437,11 @@ function openPlayerSwitcher(opts, currentIndex, anchor) {
403
437
  heading.textContent = '画面を切り替え';
404
438
  heading.style.cssText = 'font-size:11px;color:#a0a8b8;padding:2px 8px 6px;';
405
439
  panel.appendChild(heading);
406
- for (let i = 0; i < opts.seats.length; i++) {
407
- const seat = seatAt(opts, i);
440
+ for (let i = 0; i < paneCount(opts); i++) {
441
+ const pane = paneAt(opts, i);
408
442
  const isCurrent = i === currentIndex;
409
443
  const row = document.createElement('button');
410
- row.textContent = `${isCurrent ? '● ' : ''}${seat.nickname} (${seat.id})`;
444
+ row.textContent = `${isCurrent ? '● ' : ''}${pane.nickname} (${pane.id})`;
411
445
  row.style.cssText = [
412
446
  'text-align:left;border:none;border-radius:6px',
413
447
  'padding:10px 12px;font-size:14px;cursor:pointer',
@@ -467,7 +501,7 @@ onUzuClick) {
467
501
  'flex-shrink:0',
468
502
  ].join(';');
469
503
  const mobileUrl = resolveMobileUrl(opts, playerIndex);
470
- const label = seatAt(opts, playerIndex).nickname;
504
+ const label = paneAt(opts, playerIndex).nickname;
471
505
  if (onUzuClick) {
472
506
  uzu.onclick = onUzuClick;
473
507
  }
@@ -476,7 +510,7 @@ onUzuClick) {
476
510
  stateGetter: opts.stateGetter,
477
511
  resetGame: opts.resetGame,
478
512
  playerCount: opts.playerCount,
479
- adminEnabled: opts.seats.some((s) => s.kind === 'admin'),
513
+ adminEnabled: opts.admin,
480
514
  onOpenSingleView: () => {
481
515
  window.open(singleViewUrl(playerIndex), '_blank', 'noopener');
482
516
  },
@@ -585,17 +619,18 @@ export function mountIframeGrid(opts) {
585
619
  iframeSplashes.clear();
586
620
  iframePredictionWarns.clear();
587
621
  notchAppliers.length = 0;
588
- for (let i = 0; i < opts.seats.length; i++) {
589
- const seat = seatAt(opts, i);
622
+ for (let i = 0; i < paneCount(opts); i++) {
623
+ const pane = paneAt(opts, i);
590
624
  const cell = document.createElement('div');
591
625
  const label = document.createElement('div');
592
- // admin は本番に存在しない dev 専用席なので、 player 席と見分く別配色にする。
593
- const labelColor = seat.kind === 'admin' ? '#ffb86c' : '#8be9fd';
626
+ // GM 席は本番に存在しない dev 専用席なので、 player 席と見分く別配色にする。
627
+ const labelColor = pane.seatKind === 'admin' ? '#ffb86c' : '#8be9fd';
594
628
  label.style.cssText = `color:${labelColor};font-size:12px;padding:2px 0;flex-shrink:0;background:#16213e;text-align:center;`;
595
629
  const nameRow = document.createElement('div');
596
- nameRow.textContent = isPlayerSeat(seat)
597
- ? `Player ${i + 1} (${seat.id})`
598
- : `${seat.kind === 'admin' ? '⚙' : '👁'} ${seat.nickname} (${seat.id})`;
630
+ nameRow.textContent =
631
+ pane.seatKind === 'player'
632
+ ? `Player ${pane.playerNo} (${pane.id})`
633
+ : `${pane.seatKind === 'admin' ? '⚙' : '👁'} ${pane.nickname} (${pane.id})`;
599
634
  label.appendChild(nameRow);
600
635
  const statusRow = createLifecycleStatusRow();
601
636
  label.appendChild(statusRow.el);
@@ -624,8 +659,8 @@ export function mountIframeGrid(opts) {
624
659
  splash.textContent = 'waiting firstFrameReady…';
625
660
  screen.appendChild(iframe);
626
661
  screen.appendChild(splash);
627
- // admin は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
628
- if (seat.kind !== 'admin') {
662
+ // GM 席は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
663
+ if (pane.seatKind !== 'admin') {
629
664
  screen.appendChild(createCellChrome(opts, i, false, {
630
665
  toggle: toggleNotchSim,
631
666
  register: (apply) => {
@@ -655,7 +690,7 @@ export function mountIframeGrid(opts) {
655
690
  });
656
691
  // 擬似ノッチ ON/OFF を screen の inset だけで切り替える (iframe は再生成しない)。
657
692
  const applyNotch = (on) => {
658
- if (seat.kind === 'admin')
693
+ if (pane.seatKind === 'admin')
659
694
  on = false;
660
695
  if (on) {
661
696
  screen.style.top = `${SIM_NOTCH.top}px`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-cli",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {