@uzuhq/code-cli 0.3.19 → 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.
@@ -19,7 +19,10 @@ import { SeededRandomImpl } from './random.js';
19
19
  * dev-server は SDK を import できないため実装を持つ。
20
20
  */
21
21
  function isServerOnlyAction(handler) {
22
- return '__serverOnly' in handler && handler.__serverOnly === true;
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);
23
26
  }
24
27
  export class GameRoom {
25
28
  logic;
@@ -331,24 +334,45 @@ export class GameRoom {
331
334
  }
332
335
  }
333
336
  async dispatchAction(actionName, payload, senderId, ackSeq) {
334
- const handler = this.logic.actions[actionName];
335
- if (!handler) {
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) {
336
342
  throw new Error(`Unknown action: ${actionName}`);
337
343
  }
338
344
  if (!this.gameState) {
339
345
  throw new Error('Game not started');
340
346
  }
347
+ // actions 由来は送信者側で先読み時に発火済みなので ack で skip されるが、
348
+ // serverActions 由来は先読みで走っていないため別枠で常に配信する。
341
349
  const events = [];
350
+ const serverEvents = [];
342
351
  const emit = (name, data) => events.push({ name, data: data ?? {} });
343
- // serverOnly handler はサーバーでしか走らないので tick を渡せる。素の handler
344
- // クライアント先読みでも同じコードが走るため、そこで再現できない値は渡さない。
345
- if (isServerOnlyAction(handler)) {
346
- await handler(this.gameState, payload ?? {}, senderId, emit, { tick: this.tickCount });
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
+ }
347
370
  }
348
- else {
349
- handler(this.gameState, payload ?? {}, senderId, emit, {});
371
+ catch (err) {
372
+ this.gameState = snapshot;
373
+ throw err;
350
374
  }
351
- this.broadcastStateDelta(events, { ack: ackSeq, from: senderId });
375
+ this.broadcastStateDelta(events, { ack: ackSeq, from: senderId, serverEvents });
352
376
  }
353
377
  handleClose(ws) {
354
378
  const attachment = this.attachments.get(ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uzuhq/code-cli",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "UZU ゲーム開発 CLI - ビルド・パブリッシュ・プロジェクト作成ツール",
5
5
  "type": "module",
6
6
  "bin": {