@uzuhq/code-cli 0.5.5 → 0.5.7

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
+ * roster の申告を player だけに落とす。 壊れた申告は null。
19
+ *
20
+ * 観測者は roster に載らない。 旧ホストは観測席も載せてくるので、 配役に混ざらないよう
21
+ * ここで落とす。
22
+ */
23
+ function parseRoster(rosterParam) {
24
+ if (!rosterParam)
25
+ return null;
26
+ let raw;
27
+ try {
28
+ raw = JSON.parse(rosterParam);
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,17 @@ 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
+ // 移行期: SDK を同梱したシナリオは `seats=` を送り続ける。
282
+ const declared = parseRoster(url.searchParams.get('players') ?? url.searchParams.get('seats'));
283
+ if (declared && declared.length > 0) {
284
+ this.players = declared;
285
+ console.log(`[GameRoom] 📋 Roster pinned: ${this.players.map((p) => p.id).join(', ')}`);
264
286
  }
265
287
  }
266
288
  console.log(`[GameRoom] 🔗 New connection: connectionId=${connectionId} playerId=${playerId} nickname=${nickname}`);
@@ -576,10 +598,7 @@ export class GameRoom {
576
598
  this.seed = opts.seed;
577
599
  }
578
600
  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
- });
601
+ this.gameState = this.logic.setup(this.setupArgs(this.random));
583
602
  this.tickCount = 0;
584
603
  // reset は新規ゲーム = 実行可能状態を意味するので pause も解除する。
585
604
  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
@@ -6,7 +6,7 @@
6
6
  * 2. child stdout/stderr を line-by-line で監視し、 `dev.readyPattern` (default vite 向け)
7
7
  * の regex match をトリガに scenario dev server の URL を確定
8
8
  * 3. harness http+WebSocket server を立て、 iframe grid + HUD を親 frame に描画
9
- * 4. `?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&seats=<json>&revisionId=dev`
9
+ * 4. `?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&players=<json>&revisionId=dev`
10
10
  * を子 iframe URL に組み立てて mount。 SDK は既存 online mode でそのまま接続する
11
11
  * 5. `window.__uzu_dev` は harness page が dev-server の管理 channel と WS で会話して expose
12
12
  */
@@ -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 メニュー。
@@ -2,7 +2,7 @@
2
2
  * Dev harness の iframe grid を parent frame に描画する。
3
3
  *
4
4
  * 子 iframe URL には `?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&
5
- * seats=<json>&revisionId=dev` を付ける。 SDK は既存 online mode で dev-server に
5
+ * players=<json>&revisionId=dev` を付ける。 SDK は既存 online mode で dev-server に
6
6
  * 接続する (本番 Cloudflare Worker と同じ shape)。
7
7
  */
8
8
  import qrcode from 'qrcode-generator';
@@ -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,77 @@ 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 の `?players=` にも渡らない。
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
+ // 移行期: 手元の scenario が同梱する SDK が旧版 (0.7.3 等) だと `?seats=` が無いだけで
333
+ // run() の入口が throw する。 旧名も併せて載せる。
334
+ const roster = JSON.stringify(opts.players.map(toWireSeat));
335
+ url.searchParams.set('players', roster);
336
+ url.searchParams.set('seats', roster);
337
+ // 自分の席種別。 観測席で GM ビューと観戦ビューを出し分けるのに要る (state からは
338
+ // 導けない)。 iframe URL 限定で WebSocket には出さない。
339
+ url.searchParams.set('seatKind', pane.seatKind);
302
340
  url.searchParams.set('revisionId', opts.revisionId);
303
341
  // SDK は解釈しないが、 scenario 側で「parent は harness page (audio consumer 無し) か」を
304
342
  // 判定するマーカーとして残す (tenbo の __uzuAudio bridge スキップ判定など)。
305
343
  url.searchParams.set('__dev_player', String(playerIndex));
306
344
  url.searchParams.set('__dev_room', opts.roomKey);
307
- if (seat.kind === 'admin') {
308
- // admin 席は本番 overlay chrome を載せないため避け領域も不要。
345
+ if (pane.seatKind === 'admin') {
346
+ // GM 席は本番 overlay chrome を載せないため避け領域も不要。
309
347
  url.searchParams.set('uzuHudInsetX', '0');
310
348
  url.searchParams.set('uzuHudInsetY', '0');
311
349
  return url.toString();
@@ -403,11 +441,11 @@ function openPlayerSwitcher(opts, currentIndex, anchor) {
403
441
  heading.textContent = '画面を切り替え';
404
442
  heading.style.cssText = 'font-size:11px;color:#a0a8b8;padding:2px 8px 6px;';
405
443
  panel.appendChild(heading);
406
- for (let i = 0; i < opts.seats.length; i++) {
407
- const seat = seatAt(opts, i);
444
+ for (let i = 0; i < paneCount(opts); i++) {
445
+ const pane = paneAt(opts, i);
408
446
  const isCurrent = i === currentIndex;
409
447
  const row = document.createElement('button');
410
- row.textContent = `${isCurrent ? '● ' : ''}${seat.nickname} (${seat.id})`;
448
+ row.textContent = `${isCurrent ? '● ' : ''}${pane.nickname} (${pane.id})`;
411
449
  row.style.cssText = [
412
450
  'text-align:left;border:none;border-radius:6px',
413
451
  'padding:10px 12px;font-size:14px;cursor:pointer',
@@ -467,7 +505,7 @@ onUzuClick) {
467
505
  'flex-shrink:0',
468
506
  ].join(';');
469
507
  const mobileUrl = resolveMobileUrl(opts, playerIndex);
470
- const label = seatAt(opts, playerIndex).nickname;
508
+ const label = paneAt(opts, playerIndex).nickname;
471
509
  if (onUzuClick) {
472
510
  uzu.onclick = onUzuClick;
473
511
  }
@@ -476,7 +514,7 @@ onUzuClick) {
476
514
  stateGetter: opts.stateGetter,
477
515
  resetGame: opts.resetGame,
478
516
  playerCount: opts.playerCount,
479
- adminEnabled: opts.seats.some((s) => s.kind === 'admin'),
517
+ adminEnabled: opts.admin,
480
518
  onOpenSingleView: () => {
481
519
  window.open(singleViewUrl(playerIndex), '_blank', 'noopener');
482
520
  },
@@ -585,17 +623,18 @@ export function mountIframeGrid(opts) {
585
623
  iframeSplashes.clear();
586
624
  iframePredictionWarns.clear();
587
625
  notchAppliers.length = 0;
588
- for (let i = 0; i < opts.seats.length; i++) {
589
- const seat = seatAt(opts, i);
626
+ for (let i = 0; i < paneCount(opts); i++) {
627
+ const pane = paneAt(opts, i);
590
628
  const cell = document.createElement('div');
591
629
  const label = document.createElement('div');
592
- // admin は本番に存在しない dev 専用席なので、 player 席と見分く別配色にする。
593
- const labelColor = seat.kind === 'admin' ? '#ffb86c' : '#8be9fd';
630
+ // GM 席は本番に存在しない dev 専用席なので、 player 席と見分く別配色にする。
631
+ const labelColor = pane.seatKind === 'admin' ? '#ffb86c' : '#8be9fd';
594
632
  label.style.cssText = `color:${labelColor};font-size:12px;padding:2px 0;flex-shrink:0;background:#16213e;text-align:center;`;
595
633
  const nameRow = document.createElement('div');
596
- nameRow.textContent = isPlayerSeat(seat)
597
- ? `Player ${i + 1} (${seat.id})`
598
- : `${seat.kind === 'admin' ? '⚙' : '👁'} ${seat.nickname} (${seat.id})`;
634
+ nameRow.textContent =
635
+ pane.seatKind === 'player'
636
+ ? `Player ${pane.playerNo} (${pane.id})`
637
+ : `${pane.seatKind === 'admin' ? '⚙' : '👁'} ${pane.nickname} (${pane.id})`;
599
638
  label.appendChild(nameRow);
600
639
  const statusRow = createLifecycleStatusRow();
601
640
  label.appendChild(statusRow.el);
@@ -624,8 +663,8 @@ export function mountIframeGrid(opts) {
624
663
  splash.textContent = 'waiting firstFrameReady…';
625
664
  screen.appendChild(iframe);
626
665
  screen.appendChild(splash);
627
- // admin は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
628
- if (seat.kind !== 'admin') {
666
+ // GM 席は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
667
+ if (pane.seatKind !== 'admin') {
629
668
  screen.appendChild(createCellChrome(opts, i, false, {
630
669
  toggle: toggleNotchSim,
631
670
  register: (apply) => {
@@ -655,7 +694,7 @@ export function mountIframeGrid(opts) {
655
694
  });
656
695
  // 擬似ノッチ ON/OFF を screen の inset だけで切り替える (iframe は再生成しない)。
657
696
  const applyNotch = (on) => {
658
- if (seat.kind === 'admin')
697
+ if (pane.seatKind === 'admin')
659
698
  on = false;
660
699
  if (on) {
661
700
  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.5",
3
+ "version": "0.5.7",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {