@uzuhq/code-cli 0.3.18 → 0.3.20
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/dist/dev-server/game-room.js +42 -4
- package/dist/dev.js +5 -2
- package/dist/harness/mount.js +59 -1
- package/package.json +1 -1
|
@@ -14,6 +14,16 @@
|
|
|
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
|
+
// handler は logic.actions[name] の結果なので undefined になりうる。型上は
|
|
23
|
+
// undefined を含まない (noUncheckedIndexedAccess 無効) ため tsc では気付けない。
|
|
24
|
+
// typeof ガードが無いと `'__serverOnly' in undefined` で TypeError になる。
|
|
25
|
+
return (typeof handler === 'function' && '__serverOnly' in handler && handler.__serverOnly === true);
|
|
26
|
+
}
|
|
17
27
|
export class GameRoom {
|
|
18
28
|
logic;
|
|
19
29
|
tickRate;
|
|
@@ -324,17 +334,45 @@ export class GameRoom {
|
|
|
324
334
|
}
|
|
325
335
|
}
|
|
326
336
|
async dispatchAction(actionName, payload, senderId, ackSeq) {
|
|
327
|
-
const
|
|
328
|
-
|
|
337
|
+
const plain = this.logic.actions[actionName];
|
|
338
|
+
// 移行期の互換: 旧 serverOnly() を actions に入れたままの logic も動かす。
|
|
339
|
+
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
340
|
+
const serverHandler = this.logic.serverActions?.[actionName] ?? legacyServerOnly;
|
|
341
|
+
if (!plain && !serverHandler) {
|
|
329
342
|
throw new Error(`Unknown action: ${actionName}`);
|
|
330
343
|
}
|
|
331
344
|
if (!this.gameState) {
|
|
332
345
|
throw new Error('Game not started');
|
|
333
346
|
}
|
|
347
|
+
// actions 由来は送信者側で先読み時に発火済みなので ack で skip されるが、
|
|
348
|
+
// serverActions 由来は先読みで走っていないため別枠で常に配信する。
|
|
334
349
|
const events = [];
|
|
350
|
+
const serverEvents = [];
|
|
335
351
|
const emit = (name, data) => events.push({ name, data: data ?? {} });
|
|
336
|
-
|
|
337
|
-
|
|
352
|
+
const serverEmit = (name, data) => serverEvents.push({ name, data: data ?? {} });
|
|
353
|
+
// action は「全部成功か全部失敗か」にする。plain が state を変更したあと
|
|
354
|
+
// serverActions が throw すると、caller の catch が __action_error を返して
|
|
355
|
+
// broadcast されないまま変更が gameState に残り、次の無関係な broadcast の diff に
|
|
356
|
+
// 紛れて漏れる。クライアントは __action_error で予測を rollback するので、
|
|
357
|
+
// 巻き戻さないとサーバー真実と表示が食い違ったままになる。
|
|
358
|
+
const snapshot = structuredClone(this.gameState);
|
|
359
|
+
try {
|
|
360
|
+
// 決定的な部分を先に走らせ、serverActions がその結果を見られるようにする。
|
|
361
|
+
if (plain && !legacyServerOnly) {
|
|
362
|
+
plain(this.gameState, payload ?? {}, senderId, emit, {});
|
|
363
|
+
}
|
|
364
|
+
if (serverHandler) {
|
|
365
|
+
await serverHandler(this.gameState, payload ?? {}, senderId, serverEmit, {
|
|
366
|
+
tick: this.tickCount,
|
|
367
|
+
random: this.random ?? new SeededRandomImpl(this.seed),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
this.gameState = snapshot;
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
this.broadcastStateDelta(events, { ack: ackSeq, from: senderId, serverEvents });
|
|
338
376
|
}
|
|
339
377
|
handleClose(ws) {
|
|
340
378
|
const attachment = this.attachments.get(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:
|
|
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
|
-
|
|
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();
|
package/dist/harness/mount.js
CHANGED
|
@@ -254,7 +254,16 @@ function createLifecycleStatusRow() {
|
|
|
254
254
|
item.appendChild(text);
|
|
255
255
|
row.appendChild(item);
|
|
256
256
|
}
|
|
257
|
-
|
|
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);
|