@uzuhq/code-cli 0.3.18 → 0.3.19

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,13 @@
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
+ * serverOnly() で wrap された handler かを判定する (SDK の isServerOnlyAction と同じ brand)。
19
+ * dev-server は SDK を import できないため実装を持つ。
20
+ */
21
+ function isServerOnlyAction(handler) {
22
+ return '__serverOnly' in handler && handler.__serverOnly === true;
23
+ }
17
24
  export class GameRoom {
18
25
  logic;
19
26
  tickRate;
@@ -333,7 +340,14 @@ export class GameRoom {
333
340
  }
334
341
  const events = [];
335
342
  const emit = (name, data) => events.push({ name, data: data ?? {} });
336
- await handler(this.gameState, payload ?? {}, senderId, emit, { tick: this.tickCount });
343
+ // serverOnly handler はサーバーでしか走らないので tick を渡せる。素の handler
344
+ // クライアント先読みでも同じコードが走るため、そこで再現できない値は渡さない。
345
+ if (isServerOnlyAction(handler)) {
346
+ await handler(this.gameState, payload ?? {}, senderId, emit, { tick: this.tickCount });
347
+ }
348
+ else {
349
+ handler(this.gameState, payload ?? {}, senderId, emit, {});
350
+ }
337
351
  this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
338
352
  }
339
353
  handleClose(ws) {
package/dist/dev.js CHANGED
@@ -107,7 +107,7 @@ export async function runDevCommand() {
107
107
  port: harnessPort,
108
108
  // 同一 LAN の実機 (スマホ等) から harness / 単一プレイヤー画面を開けるよう
109
109
  // 全 interface で listen する。 GameRoom は in-memory の dev 専用 state のみ。
110
- host: '0.0.0.0',
110
+ host: HARNESS_HOST,
111
111
  logic: loaded?.logic ?? null,
112
112
  harness: { html: harnessHtml(), js: harnessJs },
113
113
  meta,
@@ -207,13 +207,16 @@ function watchForReady(child, pattern) {
207
207
  }
208
208
  const DEFAULT_HARNESS_PORT = 10001;
209
209
  const MAX_HARNESS_PORT_ATTEMPTS = 100;
210
+ const HARNESS_HOST = '0.0.0.0';
210
211
  async function findFreePort() {
211
212
  const net = await import('net');
212
213
  const tryPort = (port) => new Promise((resolve) => {
213
214
  const server = net.createServer();
214
215
  server.unref();
215
216
  server.once('error', () => resolve(null));
216
- server.listen(port, () => {
217
+ // host は実 listen (dev-server/server.ts) と揃える。省略すると IPv6 wildcard に
218
+ // bind してしまい、別プロセスが 0.0.0.0 で掴んでいるポートを空きと誤判定する。
219
+ server.listen(port, HARNESS_HOST, () => {
217
220
  // listen(0) は OS が空きポートを動的割当するため、引数ではなく実際に割り当てられた
218
221
  // 番号を読む必要がある。close 後は address() が null を返すので閉じる前に取る。
219
222
  const address = server.address();
@@ -254,7 +254,16 @@ function createLifecycleStatusRow() {
254
254
  item.appendChild(text);
255
255
  row.appendChild(item);
256
256
  }
257
- return { el: row, dots };
257
+ // native title tooltip は的が小さく遅延も長いので、クリックで開くパネル (render 側で
258
+ // frameWrap に置く) を主にする。title は補助。
259
+ const predictionWarn = document.createElement('span');
260
+ predictionWarn.style.cssText = [
261
+ 'display:none;align-items:center;gap:3px',
262
+ 'color:#1a1a2e;background:#ffb86c;border-radius:6px',
263
+ 'padding:0 5px;cursor:pointer;user-select:none;font-weight:bold',
264
+ ].join(';');
265
+ row.appendChild(predictionWarn);
266
+ return { el: row, dots, predictionWarn };
258
267
  }
259
268
  function seatAt(opts, index) {
260
269
  const seat = opts.seats[index];
@@ -505,6 +514,7 @@ export function mountIframeGrid(opts) {
505
514
  const cells = [];
506
515
  const iframeStatusDots = new Map();
507
516
  const iframeSplashes = new Map();
517
+ const iframePredictionWarns = new Map();
508
518
  // 擬似ノッチ (各 cell 共通トグル)。 iframe を再生成せず inset だけ切り替える。
509
519
  let notchSimOn = true;
510
520
  const notchAppliers = [];
@@ -552,6 +562,7 @@ export function mountIframeGrid(opts) {
552
562
  cells.length = 0;
553
563
  iframeStatusDots.clear();
554
564
  iframeSplashes.clear();
565
+ iframePredictionWarns.clear();
555
566
  for (let i = 0; i < opts.seats.length; i++) {
556
567
  const seat = seatAt(opts, i);
557
568
  const cell = document.createElement('div');
@@ -595,9 +606,25 @@ export function mountIframeGrid(opts) {
595
606
  if (seat.kind !== 'admin') {
596
607
  screen.appendChild(createCellChrome(opts, i, false, toggleNotchSim));
597
608
  }
609
+ // 先読み警告の内訳パネル。 バッジクリックで開閉する。 画面の上に重ねるので
610
+ // ゲームの見た目は普段どおりのまま、 必要なときだけ前に出る。
611
+ const predictionPanel = document.createElement('div');
612
+ predictionPanel.style.cssText = [
613
+ 'display:none;position:absolute;z-index:20;left:6px;right:6px;top:6px',
614
+ 'background:#16213e;border:1px solid #ffb86c;border-radius:6px',
615
+ 'padding:8px 10px;font-family:monospace;font-size:10px;line-height:1.6',
616
+ 'color:#ffb86c;text-align:left;white-space:pre-wrap;cursor:pointer',
617
+ ].join(';');
618
+ predictionPanel.addEventListener('click', () => {
619
+ predictionPanel.style.display = 'none';
620
+ });
598
621
  const notchDecor = createNotchDecor();
599
622
  frameWrap.appendChild(screen);
600
623
  frameWrap.appendChild(notchDecor);
624
+ frameWrap.appendChild(predictionPanel);
625
+ statusRow.predictionWarn.addEventListener('click', () => {
626
+ predictionPanel.style.display = predictionPanel.style.display === 'none' ? 'block' : 'none';
627
+ });
601
628
  // 擬似ノッチ ON/OFF を screen の inset だけで切り替える (iframe は再生成しない)。
602
629
  const applyNotch = (on) => {
603
630
  if (seat.kind === 'admin')
@@ -628,6 +655,11 @@ export function mountIframeGrid(opts) {
628
655
  if (iframe.contentWindow) {
629
656
  iframeStatusDots.set(iframe.contentWindow, statusRow.dots);
630
657
  iframeSplashes.set(iframe.contentWindow, splash);
658
+ iframePredictionWarns.set(iframe.contentWindow, {
659
+ badge: statusRow.predictionWarn,
660
+ panel: predictionPanel,
661
+ entries: [],
662
+ });
631
663
  }
632
664
  }
633
665
  layout();
@@ -658,6 +690,32 @@ export function mountIframeGrid(opts) {
658
690
  iframeSplashes.delete(event.source);
659
691
  }
660
692
  }
693
+ if (type === 'predictionWarning') {
694
+ const slot = iframePredictionWarns.get(event.source);
695
+ const payload = data.payload;
696
+ if (!slot || typeof payload !== 'object' || payload === null)
697
+ return;
698
+ if (!('action' in payload) || !('api' in payload))
699
+ return;
700
+ const { action, api } = payload;
701
+ if (typeof action !== 'string' || typeof api !== 'string')
702
+ return;
703
+ slot.entries.push(`${action} → ${api}`);
704
+ const body = [
705
+ '⚠ 先読み中に、サーバーと結果が一致しない API が呼ばれました',
706
+ '',
707
+ ...slot.entries.map((e) => ` ${e}`),
708
+ '',
709
+ 'この action を serverOnly() で wrap するか、値を state に書かないでください。',
710
+ '直し方の詳細は console を参照。',
711
+ '',
712
+ '(クリックで閉じる)',
713
+ ].join('\n');
714
+ slot.badge.textContent = `⚠ ${slot.entries.length}`;
715
+ slot.badge.title = body;
716
+ slot.badge.style.display = 'inline-flex';
717
+ slot.panel.textContent = body;
718
+ }
661
719
  };
662
720
  window.addEventListener('message', onParentMessage);
663
721
  window.addEventListener('resize', layout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-cli",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {